输入框鼠标编辑、图例省略、信号跨视图共享选择与网格菜单布局修复

- 输入框:支持鼠标左键点击定位插入符、拖拽框选区域,并且不再限制字符长度
- 输入框:长文本使用 ETO_CLIPPED 裁剪绘制,避免字符溢出到编辑区域之外
- 图例:信号名称超过 200px 时省略号截断,不再挤压标尺区域
- 选中状态从 TabConfig 迁移到 DataSet,切换视图后信号选中状态保持一致
- 网格右键菜单:Y 轴最大值/最小值直接显示当前值,点击后原位置弹出内联编辑框
- 标尺数值改为绘制在图例下层,不再因避让图例而隐藏
- 移除已无用的 LegendBandRect,新增共享的编辑框布局与命中检测工具
- .gitignore 忽略 TestData 测试数据目录
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-09-30 17:10:23 +08:00
1 parent e3744f3eff
commit 12b3531485
19 files changed
+790 -186

No files matched your search

+3
View File
@@ -10,3 +10,6 @@ Build/
*.lst
*.map
*.exe
# 测试数据(仅本地保留,不纳入版本控制)
TestData/
+2 -1
View File
@@ -65,7 +65,8 @@ namespace jdi
TOOLBAR, /**< 工具栏。 */
PANEL, /**< 信号面板。 */
PLOT, /**< 波形绘图区。 */
SPLITTER /**< 面板分隔条。 */
SPLITTER, /**< 面板分隔条。 */
EDIT_FIELD /**< 内联输入框(拖拽框选选区)。 */
};
Application() = default;
+57 -14
View File
@@ -344,7 +344,8 @@ namespace jdi
Relayout();
// 切换标签页后把该页已选中的信号滚动到可见位置。
m_panel.ScrollToSignal(m_tabs[static_cast<std::size_t>(m_activeTab)].Selected());
// 切换标签页后把共享的选中信号滚动到可见位置(选中状态跨页保持一致)。
m_panel.ScrollToSignal(m_dataSet.Selected());
SynchronizeView(true);
UpdateWindowTitle();
@@ -418,6 +419,9 @@ namespace jdi
{
tab.SyncToDataSet(m_dataSet);
}
// 选中状态由数据层共享维护,数据变动后需校验其是否仍指向存在的信号。
m_dataSet.ValidateSelection();
m_panel.InvalidateRows();
// 强制下一次同步重新计算视图状态。
@@ -440,11 +444,12 @@ namespace jdi
}
const std::wstring name = file->fileName;
// 先按旧下标修正各标签页配置,再从数据层移除,最后统一对齐。
// 先按旧下标修正各标签页配置与共享选中项,再从数据层移除,最后统一对齐。
for (TabConfig& tab : m_tabs)
{
tab.RemapAfterFileRemoval(fileIndex);
}
m_dataSet.RemapSelectionAfterFileRemoval(fileIndex);
m_dataSet.RemoveFile(fileIndex);
SyncTabsToDataSet();
@@ -921,6 +926,15 @@ namespace jdi
return m_toolbar.OnMouseMove(x, y);
case Target::TAB_BAR:
return m_tabBar.OnMouseMove(x, y);
case Target::EDIT_FIELD:
{
// 输入框拖拽框选:按当前正在编辑的组件分发。
bool changed = m_tabBar.OnEditMouseMove(x);
changed = m_panel.OnEditMouseMove(x) || changed;
changed = m_plot.OnEditMouseMove(x) || changed;
changed = m_colorPicker.OnEditMouseMove(x) || changed;
return changed;
}
case Target::NONE:
default:
return false;
@@ -1107,6 +1121,21 @@ namespace jdi
const bool isAuto = m_plot.LaneRangeIsAuto(laneIndex);
// 当前网格的 Y 轴上下限值直接在菜单项中显示,单击即可直接改写。
double laneYMin = 0.0;
double laneYMax = 1.0;
m_plot.LaneRange(laneIndex, laneYMin, laneYMax);
MenuItem maxItem;
maxItem.label = L"Y 轴最大值:" + FormatValue(laneYMax);
maxItem.id = PLOT_CMD_LANE_Y_MAX;
items.push_back(maxItem);
MenuItem minItem;
minItem.label = L"Y 轴最小值:" + FormatValue(laneYMin);
minItem.id = PLOT_CMD_LANE_Y_MIN;
items.push_back(minItem);
MenuItem autoItem;
autoItem.label = isAuto ? L"Y 轴自适应(当前)" : L"恢复 Y 轴自适应";
autoItem.id = PLOT_CMD_LANE_AUTO;
@@ -1114,16 +1143,6 @@ namespace jdi
autoItem.enabled = !isAuto;
items.push_back(autoItem);
MenuItem maxItem;
maxItem.label = L"设置 Y 轴最大值…";
maxItem.id = PLOT_CMD_LANE_Y_MAX;
items.push_back(maxItem);
MenuItem minItem;
minItem.label = L"设置 Y 轴最小值…";
minItem.id = PLOT_CMD_LANE_Y_MIN;
items.push_back(minItem);
MenuItem separator;
separator.kind = MenuItemKind::SEPARATOR;
items.push_back(separator);
@@ -1175,10 +1194,10 @@ namespace jdi
m_plot.ResetLaneRange(laneIndex);
break;
case PLOT_CMD_LANE_Y_MAX:
m_plot.BeginLaneValueEdit(laneIndex, true);
m_plot.BeginLaneValueEdit(laneIndex, true, result.itemRect);
break;
case PLOT_CMD_LANE_Y_MIN:
m_plot.BeginLaneValueEdit(laneIndex, false);
m_plot.BeginLaneValueEdit(laneIndex, false, result.itemRect);
break;
case PLOT_CMD_LANE_FIT_TIME:
m_plot.FitTimeToLane(laneIndex);
@@ -1475,6 +1494,16 @@ namespace jdi
const int x = GET_X_LPARAM(lParam);
const int y = GET_Y_LPARAM(lParam);
// 编辑态下点击输入框内部:定位插入符并开始拖拽框选,不再触发底层控件的选中/拖拽。
if (m_tabBar.OnEditMouseDown(x, y) || m_panel.OnEditMouseDown(x, y) ||
m_plot.OnEditMouseDown(x, y) || m_colorPicker.OnEditMouseDown(x, y))
{
SetCapture(hwnd);
m_activeTarget = Target::EDIT_FIELD;
InvalidateRect(hwnd, nullptr, FALSE);
return 0;
}
// 内联数值输入处于活动态时,点击绘图区之外视为提交(与线宽输入一致)。
if (m_plot.IsEditing() && !m_plot.HitTest(x, y))
{
@@ -1557,6 +1586,20 @@ namespace jdi
case Target::SPLITTER:
m_draggingSplitter = false;
break;
case Target::EDIT_FIELD:
{
// 结束输入框内的拖拽框选(选区保留)。
bool changed = m_tabBar.OnEditMouseUp();
changed = m_panel.OnEditMouseUp() || changed;
changed = m_plot.OnEditMouseUp() || changed;
changed = m_colorPicker.OnEditMouseUp() || changed;
m_activeTarget = Target::NONE;
if (changed)
{
InvalidateRect(hwnd, nullptr, FALSE);
}
return 0;
}
case Target::TAB_BAR:
{
TabBarResult result;
+48 -12
View File
@@ -82,7 +82,7 @@ namespace jdi
* @param [IN] dataSet 数据集合,允许为空。
* @param [IN] tab 当前标签页配置,允许为空。
*/
void SetContext(const DataSet* dataSet, TabConfig* tab);
void SetContext(DataSet* dataSet, TabConfig* tab);
/**
* @brief 设置 DPI 缩放比例。
@@ -287,7 +287,16 @@ namespace jdi
* @param [IN] editMax 为 true 编辑上限,为 false 编辑下限。
* @return 进入成功返回 true。
*/
bool BeginLaneValueEdit(int laneIndex, bool editMax);
bool BeginLaneValueEdit(int laneIndex, bool editMax, const gd::RectF& anchorRect);
/**
* @brief 查询某网格当前的 Y 轴显示范围。
* @param [IN] laneIndex 网格下标。
* @param [OUT] yMin 输出下限。
* @param [OUT] yMax 输出上限。
* @return 网格下标合法返回 true。
*/
bool LaneRange(int laneIndex, double& yMin, double& yMax) const;
/**
* @brief 判断是否处于任意内联数值输入状态。
@@ -324,6 +333,27 @@ namespace jdi
*/
bool CollapseEditSelectionToEnd();
/**
* @brief 编辑态下在输入框内按下鼠标左键:定位插入符并开始拖拽框选。
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
* @return 命中输入框返回 true。
*/
bool OnEditMouseDown(int x, int y);
/**
* @brief 编辑态下拖拽鼠标:扩展选区。
* @param [IN] x 客户区横坐标。
* @return 选区发生变化返回 true。
*/
bool OnEditMouseMove(int x);
/**
* @brief 编辑态下松开鼠标左键:结束拖拽框选。
* @return 之前处于拖拽中返回 true。
*/
bool OnEditMouseUp();
/**
* @brief 获取当前视图左边界时间。
* @return 视图左边界时间。
@@ -402,6 +432,18 @@ namespace jdi
*/
bool LaneSpansMultipleFiles(int laneIndex) const;
/**
* @brief 获取当前选中信号(跨标签页共享,切换视图后选中项保持一致)。
* @return 信号引用。
*/
const SignalRef& SelectedRef() const;
/**
* @brief 设置当前选中信号(跨标签页共享)。
* @param [IN] ref 信号引用。
*/
void SelectSignal(const SignalRef& ref);
/**
* @brief 重新计算各网格的布局与幅值范围。
*/
@@ -533,7 +575,8 @@ namespace jdi
* @param [IN] initialText 初始文本。
* @return 进入成功返回 true。
*/
bool BeginEdit(PlotEditTarget target, int laneIndex, const std::wstring& initialText);
bool BeginEdit(PlotEditTarget target, int laneIndex, const std::wstring& initialText,
const gd::RectF& anchorRect = gd::RectF());
/**
* @brief 结束内联输入。
@@ -641,7 +684,8 @@ namespace jdi
const UiFonts* m_fonts = nullptr; /**< 字体资源。 */
const Theme* m_theme = nullptr; /**< 主题资源。 */
const DataSet* m_dataSet = nullptr; /**< 数据集合。 */
const DataSet* m_dataSet = nullptr; /**< 数据集合(只读访问)。 */
DataSet* m_dataSetWritable = nullptr; /**< 数据集合(选中状态写入)。 */
TabConfig* m_tab = nullptr; /**< 当前标签页配置。 */
TabConfig m_localTab; /**< 未绑定标签页时使用的占位配置。 */
HWND m_host = nullptr; /**< 剪贴板所有者窗口句柄。 */
@@ -705,14 +749,6 @@ namespace jdi
* @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
+122 -67
View File
@@ -36,6 +36,7 @@ namespace jdi
const float kLegendPad = 5.0f; /**< 图例相对网格左上角的内缩。 */
const float kLegendSwatchSize = 8.0f; /**< 图例色块尺寸。 */
const float kLegendItemGap = 14.0f; /**< 图例条目之间的间距。 */
const float kLegendMaxTextWidth = 200.0f; /**< 图例条目的文本宽度上限(超出则省略)。 */
/**
* @brief 将数值限制到合法区间。
@@ -95,9 +96,10 @@ namespace jdi
m_theme = theme;
}
void PlotView::SetContext(const DataSet* dataSet, TabConfig* tab)
void PlotView::SetContext(DataSet* dataSet, TabConfig* tab)
{
m_dataSet = dataSet;
m_dataSetWritable = dataSet;
m_tab = tab;
m_hoverLane = -1;
m_dragMode = PlotDragMode::NONE;
@@ -216,6 +218,20 @@ namespace jdi
BuildLaneVisuals();
}
const SignalRef& PlotView::SelectedRef() const
{
static const SignalRef fallback;
return (m_dataSet != nullptr) ? m_dataSet->Selected() : fallback;
}
void PlotView::SelectSignal(const SignalRef& ref)
{
if (m_dataSetWritable != nullptr)
{
m_dataSetWritable->SetSelected(ref);
}
}
void PlotView::ClampLaneScroll()
{
const float maxOffset = std::max(0.0f, m_laneContentHeight - m_plotRect.Height);
@@ -837,7 +853,7 @@ namespace jdi
BuildLaneVisuals();
}
bool PlotView::BeginLaneValueEdit(int laneIndex, bool editMax)
bool PlotView::BeginLaneValueEdit(int laneIndex, bool editMax, const gd::RectF& anchorRect)
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
@@ -855,7 +871,27 @@ namespace jdi
const LaneVisual& visual = m_lanes[static_cast<std::size_t>(laneIndex)];
const double current = editMax ? visual.yMax : visual.yMin;
return BeginEdit(editMax ? PlotEditTarget::LANE_Y_MAX : PlotEditTarget::LANE_Y_MIN, laneIndex,
FormatValue(current));
FormatValue(current), anchorRect);
}
bool PlotView::LaneRange(int laneIndex, double& yMin, double& yMax) const
{
if (laneIndex < 0 || laneIndex >= static_cast<int>(m_lanes.size()))
{
// 菜单弹出时几何可能尚未建立,回退到配置内的自适应范围。
if (m_tab == nullptr || m_dataSet == nullptr || laneIndex < 0 ||
laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return false;
}
m_tab->ComputeLaneRange(*m_dataSet, laneIndex, yMin, yMax);
return true;
}
const LaneVisual& visual = m_lanes[static_cast<std::size_t>(laneIndex)];
yMin = visual.yMin;
yMax = visual.yMax;
return true;
}
SignalRef PlotView::HitTestSignal(int x, int y) const
@@ -988,7 +1024,18 @@ namespace jdi
return gd::RectF(m_editFieldRect.X, m_editFieldRect.Y, nameWidth, height);
}
// 网格 Y 轴上下限输入框贴在该网格内侧标尺带下方。
// 网格 Y 轴上下限输入框:优先落在被点击的菜单条目位置,保证"就地编辑"的观感。
if (m_editFieldRect.Width > 0.0f && m_editFieldRect.Height > 0.0f)
{
float left = m_editFieldRect.X;
float top = m_editFieldRect.Y;
left = std::max(m_area.X + 2.0f * m_scale,
std::min(left, m_area.X + m_area.Width - width - 2.0f * m_scale));
top = std::max(m_area.Y + 2.0f * m_scale,
std::min(top, m_area.Y + m_area.Height - height - 2.0f * m_scale));
return gd::RectF(left, top, width, height);
}
if (m_editLane < 0 || m_editLane >= static_cast<int>(m_lanes.size()))
{
return gd::RectF();
@@ -999,7 +1046,8 @@ namespace jdi
return gd::RectF(left, top, std::min(width, lane.rect.Width - 2.0f * kLaneLabelPad * m_scale), height);
}
bool PlotView::BeginEdit(PlotEditTarget target, int laneIndex, const std::wstring& initialText)
bool PlotView::BeginEdit(PlotEditTarget target, int laneIndex, const std::wstring& initialText,
const gd::RectF& anchorRect)
{
if (target == PlotEditTarget::NONE)
{
@@ -1009,15 +1057,23 @@ namespace jdi
if (target != PlotEditTarget::SIGNAL_NAME)
{
m_editSignal = SignalRef();
}
// 调用方可以用锚点矩形指定输入框位置(如菜单条目、图例条目);
// 未提供锚点时由 EditFieldRect() 按目标类型自动推导。
if (anchorRect.Width > 0.0f && anchorRect.Height > 0.0f)
{
m_editFieldRect = anchorRect;
}
else if (target != PlotEditTarget::SIGNAL_NAME)
{
m_editFieldRect = gd::RectF();
}
m_editTarget = target;
m_editLane = laneIndex;
// 数值字段限制为数字与小数点,信号名可输入任意字符(保留括号与空格)。
// 名称与数值均不限长度(数值在提交时会做范围夹取)。
const bool nameField = (target == PlotEditTarget::SIGNAL_NAME);
m_edit.Begin(initialText, true, nameField ? EditFilter::ANY : EditFilter::NUMBER,
nameField ? 96u : 18u);
m_edit.Begin(initialText, true, nameField ? EditFilter::ANY : EditFilter::NUMBER, 0u);
return true;
}
@@ -1177,6 +1233,44 @@ namespace jdi
return m_edit.DeselectToEnd();
}
bool PlotView::OnEditMouseDown(int x, int y)
{
if (m_editTarget == PlotEditTarget::NONE)
{
return false;
}
const gd::RectF rect = EditFieldRect();
if (rect.Width <= 0.0f)
{
return false;
}
return UiEditFieldMouseDown(m_fonts->uiSmall, rect, m_edit, m_scale, x, y);
}
bool PlotView::OnEditMouseMove(int x)
{
if (m_editTarget == PlotEditTarget::NONE)
{
return false;
}
const gd::RectF rect = EditFieldRect();
if (rect.Width <= 0.0f)
{
return false;
}
return UiEditFieldMouseMove(m_fonts->uiSmall, rect, m_edit, m_scale, x);
}
bool PlotView::OnEditMouseUp()
{
if (!m_edit.Dragging())
{
return false;
}
m_edit.EndDrag();
return true;
}
bool PlotView::OnEditKey(WPARAM key)
{
if (m_editTarget == PlotEditTarget::NONE)
@@ -1508,7 +1602,7 @@ namespace jdi
const SignalRef legendHit = LegendAtPoint(x, y);
if (legendHit.IsValid())
{
Tab().SetSelected(legendHit);
SelectSignal(legendHit);
m_dragMode = PlotDragMode::NONE;
return true;
}
@@ -1520,7 +1614,7 @@ namespace jdi
{
m_dragSignal = hit;
m_dragTargetLane = Tab().LaneOfSignal(hit);
Tab().SetSelected(hit);
SelectSignal(hit);
m_dragMouseX = x;
m_dragMouseY = y;
m_dragMode = PlotDragMode::MOVE_SIGNAL;
@@ -1633,7 +1727,7 @@ namespace jdi
const ResolvedSignal resolved = ResolveSignal(legendHit);
if (resolved.data != nullptr)
{
Tab().SetSelected(legendHit);
SelectSignal(legendHit);
m_editSignal = legendHit;
// 先把输入框位置落在被双击的条目上,再进入编辑态。
@@ -2077,9 +2171,6 @@ namespace jdi
// 内侧纵轴标尺作为覆盖层绘制:每个刻度数值带半透明底,保证在波形之上仍清晰可读,
// 同时只在数值背后遮挡波形,不牺牲波形本身的可见范围。
{
// 图例固定占用网格左上角,冲突时优先显示图例,故落在图例带内的刻度数值直接跳过。
const gd::RectF legendBand = LegendBandRect(laneIndex, lane);
const bool showLabels = (lane.rect.Height >= 34.0f * m_scale);
float lastLabelY = -1.0e9f;
@@ -2117,16 +2208,10 @@ 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+ 裁剪约束)。
// 与图例重叠时不再跳过:标尺值先绘制,随后绘制的图例会盖在其上层
// (顶层始终为图例),因此两者都不丢失。
// 但仍要求数值完整落在可见区内——否则会在网格分界处露出被截断的半行数字,
// 且 GDI 文本不受 GDI+ 裁剪约束会越过绘图区(改用 UiTextAtClipped 兜底)。
if (!IsFullyVisible(pill, clipTop, clipBottom))
{
continue;
@@ -2152,43 +2237,6 @@ 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.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);
@@ -2248,7 +2296,9 @@ namespace jdi
label += L" · " + resolved.file->Title();
}
const float itemWidth = kLegendSwatchSize * m_scale + 5.0f * m_scale +
UiTextWidth(g, label, m_fonts->uiSmall) + kLegendItemGap * m_scale;
std::min(UiTextWidth(g, label, m_fonts->uiSmall),
kLegendMaxTextWidth * m_scale) +
kLegendItemGap * m_scale;
if (cursorX + legendWidth + itemWidth > maxX)
{
break;
@@ -2292,9 +2342,14 @@ namespace jdi
{
label += L" · " + resolved.file->Title();
}
const float textWidth = UiTextWidth(g, label, m_fonts->uiSmall);
const float itemWidth =
kLegendSwatchSize * m_scale + 5.0f * m_scale + textWidth + kLegendItemGap * m_scale;
// 文本宽度按上限夹取,超出部分用省略号(与测量阶段保持一致)。
const float maxTextWidth = kLegendMaxTextWidth * m_scale;
const float fullTextWidth = UiTextWidth(g, label, m_fonts->uiSmall);
const float textWidth = std::min(fullTextWidth, maxTextWidth);
const std::wstring drawLabel =
(fullTextWidth > maxTextWidth) ? UiEllipsize(g, label, m_fonts->uiSmall, maxTextWidth) : label;
const float itemWidth = kLegendSwatchSize * m_scale + 5.0f * m_scale + textWidth +
kLegendItemGap * m_scale;
if (drawnWidth + itemWidth > legendWidth + 0.5f)
{
break;
@@ -2305,7 +2360,7 @@ namespace jdi
const float swatchSize = kLegendSwatchSize * m_scale;
// 图例条目与被选中的信号保持一致的高亮反馈(与左侧列表选中态呼应)。
const bool selected = (m_tab->Selected() == ref);
const bool selected = (SelectedRef() == ref);
const bool hovered = (ref == m_hoverSignal) || (ref == m_dragSignal);
if (selected || hovered)
{
@@ -2334,7 +2389,7 @@ namespace jdi
}
cursorX += swatchSize + 5.0f * m_scale;
UiText(g, label, m_fonts->uiSmall,
UiText(g, drawLabel, 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;
@@ -188,6 +188,27 @@ namespace jdi
*/
bool CollapseEditSelectionToEnd();
/**
* @brief 编辑态下在输入框内按下鼠标左键:定位插入符并开始拖拽框选。
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
* @return 命中输入框返回 true。
*/
bool OnEditMouseDown(int x, int y);
/**
* @brief 编辑态下拖拽鼠标:扩展选区。
* @param [IN] x 客户区横坐标。
* @return 选区发生变化返回 true。
*/
bool OnEditMouseMove(int x);
/**
* @brief 编辑态下松开鼠标左键:结束拖拽框选。
* @return 之前处于拖拽中返回 true。
*/
bool OnEditMouseUp();
/**
* @brief 获取指定信号的显示外观(只读,全局共享)。
* @param [IN] ref 信号引用。
@@ -202,6 +223,18 @@ namespace jdi
*/
SignalStyle* StyleOf(const SignalRef& ref);
/**
* @brief 获取当前选中信号(跨标签页共享,因此切换视图后选中项保持一致)。
* @return 信号引用。
*/
const SignalRef& SelectedRef() const;
/**
* @brief 设置当前选中信号(跨标签页共享)。
* @param [IN] ref 信号引用。
*/
void SelectSignal(const SignalRef& ref);
/**
* @brief 将浮动拾色器的取色结果应用到当前选中信号。
* @param [IN] color 0xAARRGGBB 颜色值。
+60 -18
View File
@@ -715,7 +715,7 @@ namespace jdi
}
const gd::RectF rect = RowRect(row);
const bool selected = (m_tab->Selected() == ref);
const bool selected = (SelectedRef() == ref);
const bool hovered = (m_hoverRow == row);
if (selected)
@@ -818,8 +818,8 @@ namespace jdi
UiFillRect(g, m_styleRect, m_theme->panelBg);
const SignalStyle* style = (m_tab == nullptr) ? nullptr : StyleOf(m_tab->Selected());
if (style == nullptr || m_tab->Selected().IsValid() == false)
const SignalStyle* style = (m_tab == nullptr) ? nullptr : StyleOf(SelectedRef());
if (style == nullptr || SelectedRef().IsValid() == false)
{
UiText(g, L"请选择信号以编辑样式", m_fonts->uiSmall, m_styleRect, m_theme->textMuted,
TextAlign::CENTER);
@@ -964,7 +964,7 @@ namespace jdi
{
return false;
}
SignalStyle* style = StyleOf(m_tab->Selected());
SignalStyle* style = StyleOf(SelectedRef());
if (style == nullptr)
{
return false;
@@ -1043,7 +1043,7 @@ namespace jdi
{
case StyleEditField::WIDTH:
{
const SignalStyle* style = StyleOf(m_tab->Selected());
const SignalStyle* style = StyleOf(SelectedRef());
if (style == nullptr)
{
return false;
@@ -1053,7 +1053,7 @@ namespace jdi
}
case StyleEditField::OPACITY:
{
const SignalStyle* style = StyleOf(m_tab->Selected());
const SignalStyle* style = StyleOf(SelectedRef());
if (style == nullptr)
{
return false;
@@ -1100,8 +1100,8 @@ namespace jdi
// 名称类字段可输入任意字符,数值类字段限制为数字与小数点。
const bool nameField =
(field == StyleEditField::SIGNAL_NAME) || (field == StyleEditField::FILE_NAME);
m_edit.Begin(std::move(initial), true, nameField ? EditFilter::ANY : EditFilter::NUMBER,
nameField ? 64u : 6u);
// 不限长度:名称与数值都由各自的提交逻辑处理超长内容。
m_edit.Begin(std::move(initial), true, nameField ? EditFilter::ANY : EditFilter::NUMBER, 0u);
return true;
}
@@ -1140,7 +1140,7 @@ namespace jdi
{
break;
}
if (SignalStyle* style = StyleOf(m_tab->Selected()))
if (SignalStyle* style = StyleOf(SelectedRef()))
{
const float value = ClampFloat(static_cast<float>(parsed), kWidthMin, kWidthMax);
style->lineWidth = std::round(value * 10.0f) / 10.0f;
@@ -1159,7 +1159,7 @@ namespace jdi
{
break;
}
if (SignalStyle* style = StyleOf(m_tab->Selected()))
if (SignalStyle* style = StyleOf(SelectedRef()))
{
style->opacity = ClampFloat(static_cast<float>(parsed) / 100.0f, 0.0f, 1.0f);
}
@@ -1299,6 +1299,34 @@ namespace jdi
return m_edit.DeselectToEnd();
}
bool SignalPanel::OnEditMouseDown(int x, int y)
{
if (m_editField == StyleEditField::NONE)
{
return false;
}
return UiEditFieldMouseDown(m_fonts->ui, EditFieldRect(), m_edit, m_scale, x, y);
}
bool SignalPanel::OnEditMouseMove(int x)
{
if (m_editField == StyleEditField::NONE)
{
return false;
}
return UiEditFieldMouseMove(m_fonts->ui, EditFieldRect(), m_edit, m_scale, x);
}
bool SignalPanel::OnEditMouseUp()
{
if (!m_edit.Dragging())
{
return false;
}
m_edit.EndDrag();
return true;
}
const SignalStyle* SignalPanel::StyleOf(const SignalRef& ref) const
{
return (m_dataSet != nullptr) ? m_dataSet->Style(ref) : nullptr;
@@ -1309,13 +1337,27 @@ namespace jdi
return (m_dataSetWritable != nullptr) ? m_dataSetWritable->Style(ref) : nullptr;
}
const SignalRef& SignalPanel::SelectedRef() const
{
static const SignalRef fallback;
return (m_dataSet != nullptr) ? m_dataSet->Selected() : fallback;
}
void SignalPanel::SelectSignal(const SignalRef& ref)
{
if (m_dataSetWritable != nullptr)
{
m_dataSetWritable->SetSelected(ref);
}
}
bool SignalPanel::ApplyColor(std::uint32_t color)
{
if (m_tab == nullptr)
{
return false;
}
SignalStyle* style = StyleOf(m_tab->Selected());
SignalStyle* style = StyleOf(SelectedRef());
if (style == nullptr)
{
return false;
@@ -1334,7 +1376,7 @@ namespace jdi
{
return false;
}
SignalStyle* style = StyleOf(m_tab->Selected());
SignalStyle* style = StyleOf(SelectedRef());
if (style == nullptr)
{
return false;
@@ -1415,7 +1457,7 @@ namespace jdi
{
hoverStyle = StyleHit::TOGGLE_ALL;
}
else if (m_tab != nullptr && StyleOf(m_tab->Selected()) != nullptr)
else if (m_tab != nullptr && StyleOf(SelectedRef()) != nullptr)
{
for (int index = 0; index < m_colorCount; ++index)
{
@@ -1528,7 +1570,7 @@ namespace jdi
}
// 样式编辑区。
if (SignalStyle* style = StyleOf(m_tab->Selected()))
if (SignalStyle* style = StyleOf(SelectedRef()))
{
int paletteCount = 0;
const std::uint32_t* palette = SignalPalette(paletteCount);
@@ -1612,7 +1654,7 @@ namespace jdi
}
const SignalRef ref{item.fileIndex, item.signalIndex};
m_tab->SetSelected(ref);
SelectSignal(ref);
if (part == RowPart::CHECK)
{
@@ -1656,7 +1698,7 @@ namespace jdi
const float fy = static_cast<float>(y);
if (UiPointInRect(m_customChipRect, fx, fy) && m_tab != nullptr)
{
if (const SignalStyle* style = StyleOf(m_tab->Selected()))
if (const SignalStyle* style = StyleOf(SelectedRef()))
{
changed = OpenColorPickerPopup(*style) || changed;
}
@@ -1739,7 +1781,7 @@ namespace jdi
}
const SignalRef ref{item.fileIndex, item.signalIndex};
m_tab->SetSelected(ref);
SelectSignal(ref);
m_rowsDirty = true;
return BeginEdit(StyleEditField::SIGNAL_NAME, row);
}
@@ -1766,7 +1808,7 @@ namespace jdi
}
const SignalRef ref{item.fileIndex, item.signalIndex};
m_tab->SetSelected(ref);
SelectSignal(ref);
OpenSignalMenu(ref, x, y);
return true;
}
+27
View File
@@ -163,6 +163,33 @@ namespace jdi
*/
bool CollapseEditSelectionToEnd();
/**
* @brief 编辑态下在输入框内按下鼠标左键:定位插入符并开始拖拽框选。
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
* @return 命中输入框返回 true。
*/
bool OnEditMouseDown(int x, int y);
/**
* @brief 编辑态下拖拽鼠标:扩展选区。
* @param [IN] x 客户区横坐标。
* @return 选区发生变化返回 true。
*/
bool OnEditMouseMove(int x);
/**
* @brief 编辑态下松开鼠标左键:结束拖拽框选。
* @return 之前处于拖拽中返回 true。
*/
bool OnEditMouseUp();
/**
* @brief 计算重命名输入框的矩形(绘制与命中测试共用同一套几何)。
* @return 输入框矩形;不在编辑态时返回空矩形。
*/
gd::RectF EditRect() const;
/**
* @brief 查询拖拽排序时应当插入的位置。
* @param [IN] x 客户区横坐标。
+44 -5
View File
@@ -176,10 +176,7 @@ namespace jdi
// 重命名编辑态:在标签本体之上叠加输入框(标签底色与边框仍然保留)。
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_edit, m_fonts->ui, m_scale);
UiDrawEditField(g, EditRect(), m_edit, m_fonts->ui, m_scale);
}
else
{
@@ -423,7 +420,7 @@ namespace jdi
}
m_editIndex = index;
m_edit.Begin(m_tabs[static_cast<std::size_t>(index)].name, true, EditFilter::ANY, 48);
m_edit.Begin(m_tabs[static_cast<std::size_t>(index)].name, true, EditFilter::ANY, 0u);
return true;
}
@@ -529,6 +526,48 @@ namespace jdi
return m_edit.DeselectToEnd();
}
gd::RectF TabBar::EditRect() const
{
if (m_editIndex < 0 || m_editIndex >= static_cast<int>(m_tabs.size()) || m_fonts == nullptr)
{
return gd::RectF();
}
const TabItem& tab = m_tabs[static_cast<std::size_t>(m_editIndex)];
const float textLeft = tab.rect.X + kTabPadding * m_scale;
const float textWidth = tab.closeRect.X - kCloseGap * m_scale - textLeft;
return gd::RectF(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);
}
bool TabBar::OnEditMouseDown(int x, int y)
{
if (m_editIndex < 0)
{
return false;
}
return UiEditFieldMouseDown(m_fonts->ui, EditRect(), m_edit, m_scale, x, y);
}
bool TabBar::OnEditMouseMove(int x)
{
if (m_editIndex < 0)
{
return false;
}
return UiEditFieldMouseMove(m_fonts->ui, EditRect(), m_edit, m_scale, x);
}
bool TabBar::OnEditMouseUp()
{
if (!m_edit.Dragging())
{
return false;
}
m_edit.EndDrag();
return true;
}
bool TabBar::OnRButtonDown(int x, int y, int screenX, int screenY, TabBarResult& result)
{
result = TabBarResult();
+21
View File
@@ -124,6 +124,27 @@ namespace jdi
*/
bool CollapseEditSelectionToEnd();
/**
* @brief 编辑态下在输入框内按下鼠标左键:定位插入符并开始拖拽框选。
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
* @return 命中输入框返回 true。
*/
bool OnEditMouseDown(int x, int y);
/**
* @brief 编辑态下拖拽鼠标:扩展选区。
* @param [IN] x 客户区横坐标。
* @return 选区发生变化返回 true。
*/
bool OnEditMouseMove(int x);
/**
* @brief 编辑态下松开鼠标左键:结束拖拽框选。
* @return 之前处于拖拽中返回 true。
*/
bool OnEditMouseUp();
/**
* @brief 设置剪贴板所有者窗口句柄(用于输入框的复制/粘贴)。
* @param [IN] owner 宿主窗口句柄,允许为空。
+25 -13
View File
@@ -249,8 +249,33 @@ namespace jdi
*/
double MaxTime() const;
/**
* @brief 获取当前选中的信号(全标签页共享)。
* @return 信号引用。
*/
const SignalRef& Selected() const { return m_selected; }
/**
* @brief 设置当前选中的信号(全标签页共享)。
* @param [IN] ref 信号引用;传入无效值表示取消选择。
*/
void SetSelected(const SignalRef& ref) { m_selected = ref; }
/**
* @brief 在数据层移除某文件之前,按旧下标修正选中引用。
* @param [IN] fileIndex 即将被移除的文件下标。
*/
void RemapSelectionAfterFileRemoval(int fileIndex);
/**
* @brief 校验选中引用是否仍指向存在的信号,失效则取消选中。
* @return 选中状态发生变化返回 true。
*/
bool ValidateSelection();
private:
std::vector<DataFile> m_files; /**< 已加载文件集合。 */
SignalRef m_selected; /**< 当前选中信号(跨标签页共享)。 */
};
/**
@@ -452,18 +477,6 @@ namespace jdi
*/
void ToggleCollapsed(int fileIndex);
/**
* @brief 获取当前选中的信号引用。
* @return 信号引用。
*/
SignalRef Selected() const { return m_selected; }
/**
* @brief 设置当前选中的信号。
* @param [IN] ref 信号引用,无效值表示取消选择。
*/
void SetSelected(const SignalRef& ref) { m_selected = ref; }
/**
* @brief 计算某网格的 Y 轴显示范围。
* @param [IN] dataSet 数据集合。
@@ -513,7 +526,6 @@ namespace jdi
std::vector<bool> m_collapsed; /**< 文件分组折叠状态,按文件索引。 */
std::vector<Lane> m_lanes; /**< 网格划分。 */
LaneMode m_laneMode = LaneMode::PER_SIGNAL; /**< 网格划分策略。 */
SignalRef m_selected; /**< 当前选中信号。 */
double m_viewMin = 0.0; /**< 视图左边界时间。 */
double m_viewMax = 1.0; /**< 视图右边界时间。 */
bool m_viewInitialized = false; /**< 视图范围是否已初始化。 */
+61
View File
@@ -154,6 +154,44 @@ namespace jdi
*/
float UiMeasureTextWidth(HFONT font, const std::wstring& text);
/**
* @brief 在指定裁剪矩形内以显式起点绘制一段文本(使用 GDI ExtTextOut 裁剪)。
*
* `UiText` 走 `DrawTextW`,**不遵循 GDI+ 裁剪区**;输入框在长内容、水平滚动的情况下
* 会把字符画到框体之外。本函数用 `ETO_CLIPPING` 让 GDI 自己裁剪,彻底避免越界绘制。
*
* @param [IN,OUT] g 绘图上下文。
* @param [IN] text 文本内容。
* @param [IN] font 字体句柄。
* @param [IN] x 文本起始横坐标。
* @param [IN] rect 垂直居中参考矩形。
* @param [IN] argb 文本颜色。
* @param [IN] clipRect 裁剪矩形(超出部分不绘制)。
*/
void UiTextAtClipped(gd::Graphics& g, const std::wstring& text, HFONT font, float x, const gd::RectF& rect,
std::uint32_t argb, const gd::RectF& clipRect);
/**
* @brief 计算输入框文本的绘制起点(已考虑超出框宽时的水平滚动)。
* @param [IN] rect 输入框矩形。
* @param [IN] edit 编辑模型。
* @param [IN] font 字体句柄。
* @param [IN] scale 缩放比例。
* @param [OUT] innerWidth 框内可用宽度输出。
* @return 文本绘制起点横坐标。
*/
float UiEditTextLeft(const gd::RectF& rect, const TextEdit& edit, HFONT font, float scale,
float& innerWidth);
/**
* @brief 把输入框内的横坐标换算为字符偏移(就近取整,用于鼠标定位插入符)。
* @param [IN] font 字体句柄。
* @param [IN] text 当前文本。
* @param [IN] localX 相对文本起点的横坐标(可超出 [0, 文本宽度])。
* @return 字符偏移。
*/
std::size_t UiEditOffsetAt(HFONT font, const std::wstring& text, float localX);
/**
* @brief 在超出可用宽度时对文本做尾部省略截断。
* @param [IN,OUT] g 绘图上下文。
@@ -164,6 +202,29 @@ namespace jdi
*/
std::wstring UiEllipsize(gd::Graphics& g, const std::wstring& text, HFONT font, float maxWidth);
/**
* @brief 处理输入框内的鼠标按下:命中则把插入符定位到最近的字符边界并开始拖拽框选。
* @param [IN] font 字体句柄。
* @param [IN] rect 输入框矩形。
* @param [IN,OUT] edit 编辑模型。
* @param [IN] scale 缩放比例。
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
* @return 命中输入框并开始拖拽返回 true。
*/
bool UiEditFieldMouseDown(HFONT font, const gd::RectF& rect, TextEdit& edit, float scale, int x, int y);
/**
* @brief 处理输入框内的鼠标拖拽移动(扩展选区)。
* @param [IN] font 字体句柄。
* @param [IN] rect 输入框矩形。
* @param [IN,OUT] edit 编辑模型。
* @param [IN] scale 缩放比例。
* @param [IN] x 客户区横坐标。
* @return 选区发生变化返回 true。
*/
bool UiEditFieldMouseMove(HFONT font, const gd::RectF& rect, TextEdit& edit, float scale, int x);
/**
* @brief 在矩形中心绘制一个加号图标。
* @param [IN,OUT] g 绘图上下文。
+1
View File
@@ -61,6 +61,7 @@ namespace jdi
MenuScope scope = MenuScope::NONE; /**< 来源组件。 */
int id = 0; /**< 被选中项的命令标识。 */
int swatchIndex = -1; /**< 被选中的色块下标(非色块行为 -1)。 */
gd::RectF itemRect; /**< 被点击条目的矩形(客户区坐标),供就近弹出输入框。 */
};
/**
+32
View File
@@ -144,6 +144,37 @@ namespace jdi
*/
bool DeselectToEnd();
/**
* @brief 把插入符定位到指定偏移(单击输入框内时使用),并清除选区。
* @param [IN] offset 目标偏移(内部会夹到合法范围)。
* @return 光标或选区发生变化返回 true。
*/
bool PlaceCaret(std::size_t offset);
/**
* @brief 开始鼠标拖拽框选:把锚点与插入符都放在给定偏移。
* @param [IN] offset 起始偏移。
*/
void BeginDrag(std::size_t offset);
/**
* @brief 拖拽过程中更新插入符(锚点保持不变 → 形成选区)。
* @param [IN] offset 当前偏移。
* @return 光标或选区发生变化返回 true。
*/
bool UpdateDrag(std::size_t offset);
/**
* @brief 结束鼠标拖拽。
*/
void EndDrag() { m_dragging = false; }
/**
* @brief 判断是否正在鼠标拖拽框选。
* @return 正在拖拽返回 true。
*/
bool Dragging() const { return m_dragging; }
/**
* @brief 复制当前内容到剪贴板(存在选区时只复制选区)。
* @param [IN] owner 剪贴板所有者窗口句柄,允许为空。
@@ -204,6 +235,7 @@ namespace jdi
bool m_active = false; /**< 是否处于编辑状态。 */
bool m_fresh = false; /**< 是否处于“首次输入整体替换”状态。 */
bool m_dragging = false; /**< 是否正在鼠标拖拽框选。 */
std::wstring m_text; /**< 当前文本。 */
std::size_t m_caret = 0; /**< 光标位置。 */
std::size_t m_anchor = 0; /**< 选区锚点。 */
+30 -2
View File
@@ -214,7 +214,7 @@ namespace jdi
{
const int index = static_cast<int>(field) - 1;
m_edit.Begin(std::to_wstring(ToByte(m_channels[static_cast<std::size_t>(index)])), true,
EditFilter::BYTE, 3);
EditFilter::BYTE, 0u);
break;
}
case EditField::HEX:
@@ -223,7 +223,7 @@ namespace jdi
wchar_t buffer[16];
swprintf(buffer, sizeof(buffer) / sizeof(buffer[0]), L"%02X%02X%02X", (color >> 16) & 0xFFu,
(color >> 8) & 0xFFu, color & 0xFFu);
m_edit.Begin(std::wstring(buffer), true, EditFilter::HEX, 7);
m_edit.Begin(std::wstring(buffer), true, EditFilter::HEX, 0u);
break;
}
default:
@@ -373,6 +373,34 @@ namespace jdi
return m_edit.DeselectToEnd();
}
bool ColorPickerPopup::OnEditMouseDown(int x, int y)
{
if (m_editField == EditField::NONE)
{
return false;
}
return UiEditFieldMouseDown(m_fonts->monoSmall, EditFieldRect(), m_edit, m_scale, x, y);
}
bool ColorPickerPopup::OnEditMouseMove(int x)
{
if (m_editField == EditField::NONE)
{
return false;
}
return UiEditFieldMouseMove(m_fonts->monoSmall, EditFieldRect(), m_edit, m_scale, x);
}
bool ColorPickerPopup::OnEditMouseUp()
{
if (!m_edit.Dragging())
{
return false;
}
m_edit.EndDrag();
return true;
}
bool ColorPickerPopup::ContainsPoint(int x, int y) const
{
if (!m_open)
+31 -15
View File
@@ -550,6 +550,36 @@ namespace jdi
return std::isfinite(value) ? value : 1.0;
}
void DataSet::RemapSelectionAfterFileRemoval(int fileIndex)
{
if (fileIndex < 0)
{
return;
}
if (m_selected.fileIndex == fileIndex)
{
m_selected = SignalRef();
}
else if (m_selected.fileIndex > fileIndex)
{
--m_selected.fileIndex;
}
}
bool DataSet::ValidateSelection()
{
if (!m_selected.IsValid())
{
return false;
}
if (Resolve(m_selected) != nullptr)
{
return false;
}
m_selected = SignalRef();
return true;
}
// ============================ 采样辅助 ============================
int FindNearestSample(const DataFile& file, double time)
@@ -686,12 +716,7 @@ namespace jdi
m_visible[fileIndex].resize(file->signals.size(), false);
}
// 清理已失效的选中状态与网格引用。
if (m_selected.IsValid() && dataSet.Resolve(m_selected) == nullptr)
{
m_selected = SignalRef();
}
// 清理已失效的网格引用(选中状态由 DataSet 统一维护)。
for (Lane& lane : m_lanes)
{
std::vector<SignalRef>& refs = lane.signals;
@@ -741,15 +766,6 @@ namespace jdi
m_lanes.erase(std::remove_if(m_lanes.begin(), m_lanes.end(),
[](const Lane& lane) { return lane.signals.empty(); }),
m_lanes.end());
if (m_selected.fileIndex == fileIndex)
{
m_selected = SignalRef();
}
else if (m_selected.fileIndex > fileIndex)
{
--m_selected.fileIndex;
}
}
bool TabConfig::IsVisible(const SignalRef& ref) const
+146 -39
View File
@@ -428,6 +428,101 @@ namespace jdi
knobRadius * 2.0f);
}
float UiEditTextLeft(const gd::RectF& rect, const TextEdit& edit, HFONT font, float scale,
float& innerWidth)
{
innerWidth = std::max(4.0f, rect.Width - 12.0f * scale);
const float textLeft = rect.X + 6.0f * scale;
const std::wstring& text = edit.Text();
if (text.empty())
{
return textLeft;
}
const float textWidth = UiMeasureTextWidth(font, text);
if (textWidth <= innerWidth)
{
return textLeft;
}
// 保证插入符可见:插入符超出右边界时向左滚动,并夹到总可滚动范围内。
const float caretX = UiMeasureTextWidth(font, text.substr(0, edit.Caret()));
float scroll = 0.0f;
if (caretX > innerWidth)
{
scroll = caretX - innerWidth;
}
const float maximum = textWidth - innerWidth;
scroll = std::max(0.0f, std::min(scroll, maximum));
return textLeft - scroll;
}
std::size_t UiEditOffsetAt(HFONT font, const std::wstring& text, float localX)
{
if (text.empty() || font == nullptr)
{
return 0;
}
if (localX <= 0.0f)
{
return 0;
}
// 逐字符累加宽度,找到距离最近的字符边界(文本通常很短,线性扫描足够)。
float previous = 0.0f;
for (std::size_t index = 0; index < text.size(); ++index)
{
const float current = UiMeasureTextWidth(font, text.substr(0, index + 1));
if (localX < current)
{
// 落在本字符的左半 → 取左边界,右半 → 取右边界。
return (localX - previous <= current - localX) ? index : (index + 1);
}
previous = current;
}
return text.size();
}
void UiTextAtClipped(gd::Graphics& g, const std::wstring& text, HFONT font, float x, const gd::RectF& rect,
std::uint32_t argb, const gd::RectF& clipRect)
{
if (text.empty() || font == nullptr || clipRect.Width <= 0.0f || clipRect.Height <= 0.0f)
{
return;
}
HDC hdc = g.GetHDC();
if (hdc == nullptr)
{
return;
}
HGDIOBJ oldFont = SelectObject(hdc, font);
const int oldMode = SetBkMode(hdc, TRANSPARENT);
const COLORREF oldColor = SetTextColor(hdc, ToColorRef(argb));
TEXTMETRICW metrics = {};
GetTextMetricsW(hdc, &metrics);
const int baselineY = static_cast<int>(
std::floor(rect.Y + (rect.Height - static_cast<float>(metrics.tmHeight)) * 0.5f));
RECT clip = {};
clip.left = static_cast<LONG>(std::floor(clipRect.X));
clip.top = static_cast<LONG>(std::floor(clipRect.Y));
clip.right = static_cast<LONG>(std::ceil(clipRect.X + clipRect.Width));
clip.bottom = static_cast<LONG>(std::ceil(clipRect.Y + clipRect.Height));
// ETO_CLIPPED 让 GDI 在指定矩形内裁剪,长文本不会再画到输入框之外。
ExtTextOutW(hdc, static_cast<int>(std::floor(x)), baselineY, ETO_CLIPPED, &clip, text.c_str(),
static_cast<UINT>(text.size()), nullptr);
SetTextColor(hdc, oldColor);
SetBkMode(hdc, oldMode);
SelectObject(hdc, oldFont);
g.ReleaseHDC(hdc);
}
void UiDrawEditField(gd::Graphics& g, const gd::RectF& rect, const TextEdit& edit, HFONT font, float scale)
{
if (rect.Width <= 0.0f || rect.Height <= 0.0f || font == nullptr)
@@ -442,70 +537,52 @@ namespace jdi
UiDrawRoundRect(g, rect, corner, theme.accent, 1.6f);
const std::wstring& text = edit.Text();
const float innerLeft = rect.X + 6.0f * scale;
float innerWidth = 0.0f;
const float textLeft = UiEditTextLeft(rect, edit, font, scale, innerWidth);
const gd::RectF clipRect(innerLeft, rect.Y + 1.0f * scale, innerWidth, rect.Height - 2.0f * scale);
if (text.empty())
{
// 空内容仍绘制插入符,保持焦点可见。
const float caretX = rect.X + 6.0f * scale;
UiDrawLine(g, caretX, rect.Y + 4.0f * scale, caretX, rect.Y + rect.Height - 4.0f * scale,
UiDrawLine(g, textLeft, rect.Y + 4.0f * scale, textLeft, rect.Y + rect.Height - 4.0f * scale,
theme.accent, 1.4f * scale);
return;
}
const float innerLeft = rect.X + 6.0f * scale;
const float innerWidth = std::max(4.0f, rect.Width - 12.0f * scale);
// 光标与选区端点对应的像素位置(以文本左端为原点)。
const float caretX = UiTextWidth(g, text.substr(0, edit.Caret()), font);
const float caretX = UiMeasureTextWidth(font, text.substr(0, edit.Caret()));
const float selBeginX =
edit.HasSelection() ? UiTextWidth(g, text.substr(0, edit.SelectionBegin()), font) : 0.0f;
edit.HasSelection() ? UiMeasureTextWidth(font, text.substr(0, edit.SelectionBegin())) : 0.0f;
const float selEndX =
edit.HasSelection() ? UiTextWidth(g, text.substr(0, edit.SelectionEnd()), font) : 0.0f;
const float textWidth = UiTextWidth(g, text, font);
edit.HasSelection() ? UiMeasureTextWidth(font, text.substr(0, edit.SelectionEnd())) : 0.0f;
// 内容超出可视宽度时横向滚动,保证光标始终可见(向常规输入框靠齐)。
float scroll = 0.0f;
if (textWidth > innerWidth)
{
if (caretX > innerWidth)
{
scroll = caretX - innerWidth;
}
const float maximum = textWidth - innerWidth;
scroll = std::max(0.0f, std::min(scroll, maximum));
}
const bool fullSelection =
edit.HasSelection() && edit.SelectionBegin() == 0 && edit.SelectionEnd() == text.size();
const gd::RectF clipRect(innerLeft, rect.Y + 1.0f * scale, innerWidth, rect.Height - 2.0f * scale);
const int saveState = g.Save();
g.SetClip(clipRect, gd::CombineModeIntersect);
const float textLeft = innerLeft - scroll;
// 选区高亮绘制在文本之下,整段选中时呈现“已全选”观感。
const bool fullSelection = edit.HasSelection() && edit.SelectionBegin() == 0 &&
edit.SelectionEnd() == text.size();
// 选区高亮绘制在文本之下,整段选中时呈现“已全选”观感;
// 叠加上 GDI+ 裁剪,高亮同样不会越过输入框边界。
if (edit.HasSelection() && selEndX > selBeginX)
{
const gd::RectF highlight(textLeft + selBeginX, rect.Y + 3.0f * scale,
selEndX - selBeginX, rect.Height - 6.0f * scale);
const gd::RectF highlight(textLeft + selBeginX, rect.Y + 3.0f * scale, selEndX - selBeginX,
rect.Height - 6.0f * scale);
UiFillRect(g, highlight, theme.accent);
}
UiText(g, text, font, gd::RectF(textLeft, rect.Y, std::max(textWidth, innerWidth), rect.Height),
theme.textStrong, TextAlign::LEFT);
// 长文本用 ETO_CLIPPING 交给 GDI 裁剪,避免字符画到输入框之外。
UiTextAtClipped(g, text, font, textLeft, rect,
fullSelection ? theme.textOnAccent : theme.textStrong, clipRect);
if (edit.HasSelection() && !fullSelection && selEndX > selBeginX)
if (!fullSelection && edit.HasSelection() && selEndX > selBeginX)
{
// 局部选区:在反白底衬上重绘被选中的片段,其余文字保持常规颜色。
const std::wstring selected =
text.substr(edit.SelectionBegin(), edit.SelectionEnd() - edit.SelectionBegin());
UiText(g, selected, font,
gd::RectF(textLeft + selBeginX, rect.Y, selEndX - selBeginX, rect.Height),
theme.textOnAccent, TextAlign::LEFT);
}
else if (fullSelection)
{
UiText(g, text, font, gd::RectF(textLeft, rect.Y, std::max(textWidth, innerWidth), rect.Height),
theme.textOnAccent, TextAlign::LEFT);
UiTextAtClipped(g, selected, font, textLeft + selBeginX, rect, theme.textOnAccent, clipRect);
}
// 插入符。
@@ -516,6 +593,36 @@ namespace jdi
g.Restore(saveState);
}
bool UiEditFieldMouseDown(HFONT font, const gd::RectF& rect, TextEdit& edit, float scale, int x, int y)
{
if (!edit.Active() || font == nullptr || rect.Width <= 0.0f)
{
return false;
}
if (!UiPointInRect(rect, static_cast<float>(x), static_cast<float>(y)))
{
return false;
}
// 用与绘制完全相同的布局函数换算文本起点,保证点哪儿光标就落在哪儿。
float innerWidth = 0.0f;
const float textLeft = UiEditTextLeft(rect, edit, font, scale, innerWidth);
edit.BeginDrag(UiEditOffsetAt(font, edit.Text(), static_cast<float>(x) - textLeft));
return true;
}
bool UiEditFieldMouseMove(HFONT font, const gd::RectF& rect, TextEdit& edit, float scale, int x)
{
if (!edit.Active() || !edit.Dragging() || font == nullptr || rect.Width <= 0.0f)
{
return false;
}
float innerWidth = 0.0f;
const float textLeft = UiEditTextLeft(rect, edit, font, scale, innerWidth);
return edit.UpdateDrag(UiEditOffsetAt(font, edit.Text(), static_cast<float>(x) - textLeft));
}
bool UiSwatchNeedsOutline(float opacity)
{
return !(opacity >= kSwatchOutlineOpacity);
+2
View File
@@ -277,6 +277,7 @@ namespace jdi
result.scope = m_scope;
result.id = item.id;
result.swatchIndex = -1;
result.itemRect = m_itemRects[static_cast<std::size_t>(m_hoverIndex)];
return true;
}
@@ -353,6 +354,7 @@ namespace jdi
result.scope = m_scope;
result.id = item.id;
result.swatchIndex = -1;
result.itemRect = m_itemRects[static_cast<std::size_t>(index)];
if (item.kind == MenuItemKind::SWATCHES)
{
+45
View File
@@ -71,6 +71,7 @@ namespace jdi
{
m_active = false;
m_fresh = false;
m_dragging = false;
m_text.clear();
m_caret = 0;
m_anchor = 0;
@@ -321,6 +322,50 @@ namespace jdi
return changed;
}
bool TextEdit::PlaceCaret(std::size_t offset)
{
if (!m_active)
{
return false;
}
const std::size_t target = ClampOffset(offset);
const bool changed = HasSelection() || (m_caret != target);
m_caret = target;
m_anchor = target;
m_fresh = false;
return changed;
}
void TextEdit::BeginDrag(std::size_t offset)
{
if (!m_active)
{
return;
}
const std::size_t target = ClampOffset(offset);
m_caret = target;
m_anchor = target;
m_fresh = false;
m_dragging = true;
}
bool TextEdit::UpdateDrag(std::size_t offset)
{
if (!m_active || !m_dragging)
{
return false;
}
const std::size_t target = ClampOffset(offset);
if (target == m_caret)
{
return false;
}
m_caret = target;
m_fresh = false;
return true;
}
bool TextEdit::CopyToClipboard(HWND owner) const
{
if (m_text.empty())