feat(vtk): 段头响应式图标工具条 SectionIconBar(溢出折叠+单元测试)

This commit is contained in:
gaozheng 2026-06-30 22:18:26 +08:00
parent e970ab428e
commit b0da3c12fb
5 changed files with 219 additions and 0 deletions

View File

@ -84,6 +84,7 @@ add_executable(geopro_desktop WIN32
panels/columns/CategoryAnalysisTab.cpp panels/columns/CategoryAnalysisTab.cpp
panels/columns/DateRangeEdit.cpp panels/columns/DateRangeEdit.cpp
panels/columns/ColumnDrawer.cpp panels/columns/ColumnDrawer.cpp
panels/columns/SectionIconBar.cpp
panels/AnomalyTablePanel.cpp panels/AnomalyTablePanel.cpp
panels/LoadingOverlay.cpp panels/LoadingOverlay.cpp
panels/DatasetDetailPage.cpp panels/DatasetDetailPage.cpp

View File

@ -0,0 +1,143 @@
#include "panels/columns/SectionIconBar.hpp"
#include <QAction>
#include <QHBoxLayout>
#include <QMenu>
#include <QResizeEvent>
#include <QSize>
#include <QToolButton>
#include <algorithm>
#include "Glyphs.hpp"
#include "Theme.hpp"
namespace geopro::app {
int visibleIconCount(int totalIcons, int availablePx, int iconPx, int overflowPx, int maxIcons) {
if (totalIcons <= 0 || iconPx <= 0) return 0;
const int ideal = std::min(totalIcons, std::max(0, maxIcons));
const bool overflowFromCap = totalIcons > ideal; // 超 max → 必有溢出位
// 先看理想数能否放下(若已因 cap 溢出,理想数也要含溢出位)
auto fits = [&](int n, bool withOverflow) {
return n * iconPx + (withOverflow ? overflowPx : 0) <= availablePx;
};
int n = ideal;
bool overflow = overflowFromCap;
while (n > 0 && !fits(n, overflow || (totalIcons > n))) {
--n;
overflow = true; // 一旦减少必有「…」
}
if (n < 0) n = 0;
return n;
}
namespace {
constexpr int kGlyphPx = 18; // glyph 绘制像素;按钮本体宽用 iconPx_与溢出计算口径一致
// glyphKey 字符串 → Glyph 枚举C2 按段头操作填键;未知键回退中性图标)。
Glyph glyphFromKey(const QString& key) {
const QString k = key.toLower();
if (k == QStringLiteral("plus")) return Glyph::Plus;
if (k == QStringLiteral("filter")) return Glyph::Filter;
if (k == QStringLiteral("upload")) return Glyph::Upload;
if (k == QStringLiteral("download")) return Glyph::Download;
if (k == QStringLiteral("collapse")) return Glyph::Collapse;
if (k == QStringLiteral("fullscreen")) return Glyph::Fullscreen;
if (k == QStringLiteral("map")) return Glyph::Map;
if (k == QStringLiteral("detail")) return Glyph::Detail;
if (k == QStringLiteral("property")) return Glyph::Property;
if (k == QStringLiteral("gear")) return Glyph::Gear;
return Glyph::Property;
}
} // namespace
SectionIconBar::SectionIconBar(QWidget* parent) : QWidget(parent) {
auto* lay = new QHBoxLayout(this);
lay->setContentsMargins(0, 0, 0, 0);
lay->setSpacing(0);
}
void SectionIconBar::setActions(const std::vector<IconAction>& a) {
// 清旧按钮与溢出按钮
for (auto* b : btns_) {
if (b) b->deleteLater();
}
btns_.clear();
if (overflowBtn_) {
overflowBtn_->deleteLater();
overflowBtn_ = nullptr;
}
actions_ = a;
auto* lay = qobject_cast<QHBoxLayout*>(layout());
const QColor ic = tokenColor("text/secondary");
for (const IconAction& act : actions_) {
auto* b = new QToolButton(this);
b->setIcon(makeGlyph(glyphFromKey(act.glyphKey), ic, kGlyphPx));
b->setIconSize(QSize(kGlyphPx, kGlyphPx));
b->setAutoRaise(true);
b->setToolTip(act.tooltip);
b->setFixedSize(iconPx_, iconPx_);
if (act.popupBuilder) {
const auto builder = act.popupBuilder;
QToolButton* host = b;
connect(b, &QToolButton::clicked, this, [builder, host] { builder(host); });
} else if (act.onClick) {
const auto cb = act.onClick;
connect(b, &QToolButton::clicked, this, [cb] { cb(); });
}
if (lay) lay->addWidget(b);
btns_.push_back(b);
}
// 末尾「…」溢出按钮:即点即弹菜单
overflowBtn_ = new QToolButton(this);
overflowBtn_->setText(QStringLiteral(""));
overflowBtn_->setAutoRaise(true);
overflowBtn_->setToolTip(QStringLiteral("更多"));
overflowBtn_->setFixedSize(overflowPx_, iconPx_);
overflowBtn_->setPopupMode(QToolButton::InstantPopup);
overflowBtn_->setMenu(new QMenu(overflowBtn_));
if (lay) lay->addWidget(overflowBtn_);
relayout();
}
void SectionIconBar::resizeEvent(QResizeEvent* e) {
QWidget::resizeEvent(e);
relayout();
}
void SectionIconBar::relayout() {
const int total = static_cast<int>(actions_.size());
const int vis = visibleIconCount(total, width(), iconPx_, overflowPx_, maxIcons_);
QMenu* menu = overflowBtn_ ? overflowBtn_->menu() : nullptr;
if (menu) menu->clear();
for (int i = 0; i < total; ++i) {
QToolButton* b = btns_[static_cast<size_t>(i)];
if (!b) continue;
if (i < vis) {
b->setVisible(true);
continue;
}
b->setVisible(false);
if (!menu) continue;
const IconAction& act = actions_[static_cast<size_t>(i)];
QAction* ma = menu->addAction(act.tooltip);
if (act.popupBuilder) {
const auto builder = act.popupBuilder;
QToolButton* host = overflowBtn_;
connect(ma, &QAction::triggered, this, [builder, host] { builder(host); });
} else if (act.onClick) {
const auto cb = act.onClick;
connect(ma, &QAction::triggered, this, [cb] { cb(); });
}
}
if (overflowBtn_) overflowBtn_->setVisible(vis < total);
}
} // namespace geopro::app

View File

@ -0,0 +1,41 @@
#pragma once
#include <QWidget>
#include <QString>
#include <functional>
#include <vector>
class QToolButton;
class QResizeEvent;
namespace geopro::app {
// 纯逻辑:给定约束返回可见图标数(其余收进「…」)。见 spec §6。
int visibleIconCount(int totalIcons, int availablePx, int iconPx, int overflowPx, int maxIcons);
// 段头响应式图标工具条≤maxIcons 且宽度够则全显;否则右侧依次收进末尾「…」下拉。
struct IconAction {
QString glyphKey; // 图标键(映射到 Glyph
QString tooltip;
std::function<void()> onClick; // 直接动作;为空则用 popupBuilder
std::function<void(QToolButton*)> popupBuilder; // 弹 popupz值/底图/筛选用)
};
class SectionIconBar : public QWidget {
Q_OBJECT
public:
explicit SectionIconBar(QWidget* parent = nullptr);
void setActions(const std::vector<IconAction>& actions); // 重建按钮
void setMaxIcons(int n) { maxIcons_ = n; relayout(); }
protected:
void resizeEvent(QResizeEvent* e) override;
private:
void relayout(); // 按当前宽度算可见数,多余进「…」菜单
std::vector<IconAction> actions_;
std::vector<QToolButton*> btns_;
QToolButton* overflowBtn_ = nullptr;
int maxIcons_ = 3;
int iconPx_ = 30;
int overflowPx_ = 30;
};
} // namespace geopro::app

View File

@ -184,6 +184,16 @@ target_sources(geopro_tests PRIVATE
) )
# GS aggregateGsState + dedupeSourcesheader-only # GS aggregateGsState + dedupeSourcesheader-only
target_sources(geopro_tests PRIVATE app/test_object_tree_selection.cpp) target_sources(geopro_tests PRIVATE app/test_object_tree_selection.cpp)
# visibleIconCount spec §6SectionIconBar.cpp
# QWidget makeGlyph(Glyphs.cpp) 与主题(Theme.cpp) Qt6::Widgets/Svg
find_package(Qt6 COMPONENTS Widgets Svg REQUIRED)
target_sources(geopro_tests PRIVATE
app/test_section_icon_bar.cpp
${CMAKE_SOURCE_DIR}/src/app/panels/columns/SectionIconBar.cpp
${CMAKE_SOURCE_DIR}/src/app/Glyphs.cpp
${CMAKE_SOURCE_DIR}/src/app/Theme.cpp
)
target_link_libraries(geopro_tests PRIVATE Qt6::Widgets Qt6::Svg)
# measurement / id / / Qt6::Core JSON + core model # measurement / id / / Qt6::Core JSON + core model
target_sources(geopro_tests PRIVATE target_sources(geopro_tests PRIVATE
app/test_scatter_data_ops.cpp app/test_scatter_data_ops.cpp

View File

@ -0,0 +1,24 @@
#include <gtest/gtest.h>
#include "panels/columns/SectionIconBar.hpp"
using geopro::app::visibleIconCount;
TEST(SectionIconBar, ShowsAllWhenWideEnoughAndUnderMax) {
// 3 图标, 宽 1000, 每个 30, 溢出 30, max 3 → 全显
EXPECT_EQ(visibleIconCount(3, 1000, 30, 30, 3), 3);
}
TEST(SectionIconBar, CapsAtMaxIcons) {
// 5 图标但 max 3, 宽足够 → 显 3, 其余 2 进溢出(此时需留溢出位)
EXPECT_EQ(visibleIconCount(5, 1000, 30, 30, 3), 3);
}
TEST(SectionIconBar, FoldsRightWhenNarrow) {
// 3 图标, max 3, 但宽只够 2 个 + 溢出: 75px, 30 each, overflow 30 → 2*30+30=90>75 → 1*30+30=60<=75 → 1
EXPECT_EQ(visibleIconCount(3, 75, 30, 30, 3), 1);
}
TEST(SectionIconBar, NoOverflowReserveWhenAllFit) {
// 2 图标全显且 <=max, 不需溢出位: 宽 60 恰好 2*30
EXPECT_EQ(visibleIconCount(2, 60, 30, 30, 3), 2);
}
TEST(SectionIconBar, ZeroWhenTooNarrow) {
EXPECT_EQ(visibleIconCount(3, 20, 30, 30, 3), 0);
}