Skip to content

Commit 35e87a8

Browse files
committed
feat(qt): add interactive DonutChart widget
1 parent 2e11f61 commit 35e87a8

File tree

4 files changed

+296
-2
lines changed

4 files changed

+296
-2
lines changed

src/Makefile.qt.include

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ QT_MOC_CPP = \
6363
qt/moc_createwalletdialog.cpp \
6464
qt/moc_csvmodelwriter.cpp \
6565
qt/moc_descriptiondialog.cpp \
66+
qt/moc_donutchart.cpp \
6667
qt/moc_editaddressdialog.cpp \
6768
qt/moc_guiutil.cpp \
6869
qt/moc_informationwidget.cpp \
@@ -147,6 +148,7 @@ BITCOIN_QT_H = \
147148
qt/createwalletdialog.h \
148149
qt/csvmodelwriter.h \
149150
qt/descriptiondialog.h \
151+
qt/donutchart.h \
150152
qt/editaddressdialog.h \
151153
qt/guiconstants.h \
152154
qt/guiutil_font.h \
@@ -254,19 +256,19 @@ BITCOIN_QT_BASE_CPP = \
254256
qt/bantablemodel.cpp \
255257
qt/bitcoin.cpp \
256258
qt/bitcoinaddressvalidator.cpp \
257-
qt/masternodemodel.cpp \
258-
qt/proposalmodel.cpp \
259259
qt/bitcoinamountfield.cpp \
260260
qt/bitcoingui.cpp \
261261
qt/bitcoinunits.cpp \
262262
qt/clientfeeds.cpp \
263263
qt/clientmodel.cpp \
264264
qt/csvmodelwriter.cpp \
265+
qt/donutchart.cpp \
265266
qt/guiutil.cpp \
266267
qt/guiutil_font.cpp \
267268
qt/informationwidget.cpp \
268269
qt/initexecutor.cpp \
269270
qt/intro.cpp \
271+
qt/masternodemodel.cpp \
270272
qt/modaloverlay.cpp \
271273
qt/networkstyle.cpp \
272274
qt/networkwidget.cpp \
@@ -276,6 +278,7 @@ BITCOIN_QT_BASE_CPP = \
276278
qt/peertablemodel.cpp \
277279
qt/peertablesortproxy.cpp \
278280
qt/proposalinfo.cpp \
281+
qt/proposalmodel.cpp \
279282
qt/qvalidatedlineedit.cpp \
280283
qt/qvaluecombobox.cpp \
281284
qt/rpcconsole.cpp \

src/qt/donutchart.cpp

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
// Copyright (c) 2026 The Dash Core developers
2+
// Distributed under the MIT software license, see the accompanying
3+
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
4+
5+
#include <qt/donutchart.h>
6+
7+
#include <qt/guiutil.h>
8+
9+
#include <QMouseEvent>
10+
#include <QPainter>
11+
#include <QtMath>
12+
13+
namespace {
14+
constexpr int CHART_MARGIN{10};
15+
constexpr int TEXT_PADDING{20};
16+
} // anonymous namespace
17+
18+
DonutChart::DonutChart(QWidget* parent)
19+
: QWidget(parent)
20+
{
21+
setMouseTracking(true);
22+
setAttribute(Qt::WA_Hover);
23+
}
24+
25+
DonutChart::~DonutChart() = default;
26+
27+
void DonutChart::setData(std::vector<Slice>&& slices, double total_capacity, CenterText&& default_text)
28+
{
29+
m_slices = std::move(slices);
30+
m_total_capacity = total_capacity;
31+
m_default_center_text = std::move(default_text);
32+
m_hovered_slice = -1;
33+
update();
34+
}
35+
36+
void DonutChart::clear()
37+
{
38+
m_slices.clear();
39+
m_total_capacity = 0;
40+
m_hovered_slice = -1;
41+
m_default_center_text = {};
42+
update();
43+
}
44+
45+
QSize DonutChart::sizeHint() const
46+
{
47+
return QSize(200, 200);
48+
}
49+
50+
QSize DonutChart::minimumSizeHint() const
51+
{
52+
return QSize(150, 150);
53+
}
54+
55+
DonutChart::Geometry DonutChart::chartGeometry() const
56+
{
57+
const int effective_height{static_cast<int>(height() * 0.8)};
58+
const int side{qMin(width(), effective_height) - 2 * CHART_MARGIN};
59+
const int outer_radius{qMax(0, side / 2)};
60+
const int donut_thickness{qBound(25, outer_radius / 4, 60)};
61+
return {
62+
.m_inner_radius = qMax(0, outer_radius - donut_thickness),
63+
.m_outer_radius = outer_radius,
64+
.m_center = {width() / 2, height() / 2}
65+
};
66+
}
67+
68+
void DonutChart::paintEvent(QPaintEvent* /*event*/)
69+
{
70+
QPainter painter{this};
71+
painter.setRenderHint(QPainter::Antialiasing);
72+
73+
const auto [inner_radius, outer_radius, center] = chartGeometry();
74+
if (outer_radius <= 0) return;
75+
76+
// Draw background circle (unallocated portion)
77+
painter.setPen(Qt::NoPen);
78+
painter.setBrush(GUIUtil::getThemedQColor(GUIUtil::ThemedColor::BORDER_WIDGET));
79+
painter.drawEllipse(center, outer_radius, outer_radius);
80+
81+
// Draw slices (clamped so total never exceeds 360 degrees)
82+
if (m_total_capacity > 0 && !m_slices.empty()) {
83+
int start_angle{90 * 16}; // Start from top (Qt uses 1/16th of a degree)
84+
int remaining_angle{360 * 16};
85+
for (size_t idx{0}; idx < m_slices.size(); idx++) {
86+
if (remaining_angle <= 0) break;
87+
const auto& slice = m_slices[idx];
88+
if (slice.m_value <= 0) continue;
89+
const double fraction{slice.m_value / m_total_capacity};
90+
const int span_angle{std::max(-(remaining_angle), static_cast<int>(-fraction * 360 * 16))}; // Negative for clockwise
91+
QColor color{slice.m_color};
92+
if (static_cast<int>(idx) == m_hovered_slice) {
93+
color = color.lighter(130);
94+
}
95+
painter.setBrush(color);
96+
painter.drawPie(center.x() - outer_radius, center.y() - outer_radius,
97+
outer_radius * 2, outer_radius * 2, start_angle, span_angle);
98+
start_angle += span_angle;
99+
remaining_angle += span_angle; // span_angle is negative
100+
}
101+
}
102+
103+
// Draw inner circle
104+
painter.setBrush(GUIUtil::getThemedQColor(GUIUtil::ThemedColor::BACKGROUND_WIDGET));
105+
painter.drawEllipse(center, inner_radius, inner_radius);
106+
107+
// Draw center text
108+
painter.setPen(GUIUtil::getThemedQColor(GUIUtil::ThemedColor::DEFAULT));
109+
110+
QString line1, line2, line3;
111+
if (m_hovered_slice >= 0 && m_hovered_slice < static_cast<int>(m_slices.size())) {
112+
const auto& slice = m_slices[m_hovered_slice];
113+
line1 = slice.m_donut_center_label;
114+
line2 = slice.m_donut_sub_label1;
115+
line3 = slice.m_donut_sub_label2;
116+
} else {
117+
line1 = m_default_center_text.m_donut_center_label;
118+
line2 = m_default_center_text.m_donut_sub_label1;
119+
line3 = m_default_center_text.m_donut_sub_label2;
120+
}
121+
122+
// Calculate font sizes to fit in the inner circle - scale with widget size
123+
const int max_text_width{inner_radius * 2 - TEXT_PADDING};
124+
QFont font{painter.font()};
125+
126+
// Scale font sizes based on inner radius (min 8pt, max reasonable sizes)
127+
const int primary_font_size{qBound(9, inner_radius * 11 / 60, 20)};
128+
const int secondary_font_size{qBound(8, inner_radius * 11 / 80, 15)};
129+
130+
// Line 1 (name or allocated amount) - larger
131+
font.setPointSize(primary_font_size);
132+
font.setBold(true);
133+
painter.setFont(font);
134+
QFontMetrics fm1{font};
135+
QString elided_line1{fm1.elidedText(line1, Qt::ElideRight, max_text_width)};
136+
137+
// Lines 2 and 3 - smaller
138+
font.setPointSize(secondary_font_size);
139+
font.setBold(false);
140+
painter.setFont(font);
141+
QFontMetrics fm2{font};
142+
143+
const int line_height{fm2.height()};
144+
const int total_height{fm1.height() + line_height * 2 + 4};
145+
int y{center.y() - total_height / 2};
146+
147+
// Draw line 1
148+
font.setPointSize(primary_font_size);
149+
font.setBold(true);
150+
painter.setFont(font);
151+
painter.drawText(QRect(center.x() - max_text_width / 2, y, max_text_width, fm1.height()),
152+
Qt::AlignCenter, elided_line1);
153+
y += fm1.height() + 2;
154+
155+
// Draw lines 2 and 3
156+
font.setPointSize(secondary_font_size);
157+
font.setBold(false);
158+
painter.setFont(font);
159+
const QString elided_line2{fm2.elidedText(line2, Qt::ElideRight, max_text_width)};
160+
const QString elided_line3{fm2.elidedText(line3, Qt::ElideRight, max_text_width)};
161+
painter.drawText(QRect(center.x() - max_text_width / 2, y, max_text_width, line_height),
162+
Qt::AlignCenter, elided_line2);
163+
y += line_height;
164+
painter.drawText(QRect(center.x() - max_text_width / 2, y, max_text_width, line_height),
165+
Qt::AlignCenter, elided_line3);
166+
}
167+
168+
int DonutChart::sliceAtPosition(const QPoint& pos) const
169+
{
170+
if (m_slices.empty() || m_total_capacity <= 0) {
171+
return -1;
172+
}
173+
174+
const auto [inner_radius, outer_radius, center] = chartGeometry();
175+
176+
// Check if point is within the donut ring
177+
const int dx{pos.x() - center.x()};
178+
const int dy{pos.y() - center.y()};
179+
const double dist{qSqrt(dx * dx + dy * dy)};
180+
181+
if (dist < inner_radius || dist > outer_radius) {
182+
return -1;
183+
}
184+
185+
// Calculate angle (0 = top, clockwise)
186+
// Note: swapped and negated for top=0, clockwise
187+
double angle{qAtan2(dx, -dy)};
188+
if (angle < 0) {
189+
angle += 2 * M_PI;
190+
}
191+
192+
// Find which slice this angle falls into.
193+
// Mirror paintEvent's clamping so hit-regions match what is painted.
194+
double current_angle{0};
195+
double remaining{2 * M_PI};
196+
for (size_t idx{0}; idx < m_slices.size(); idx++) {
197+
if (remaining <= 0) break;
198+
if (m_slices[idx].m_value <= 0) continue;
199+
const double fraction{m_slices[idx].m_value / m_total_capacity};
200+
const double slice_angle{std::min(fraction * 2 * M_PI, remaining)};
201+
if (angle >= current_angle && angle < current_angle + slice_angle) {
202+
return static_cast<int>(idx);
203+
}
204+
current_angle += slice_angle;
205+
remaining -= slice_angle;
206+
}
207+
208+
return -1;
209+
}
210+
211+
void DonutChart::mouseMoveEvent(QMouseEvent* event)
212+
{
213+
const int slice{sliceAtPosition(event->pos())};
214+
if (slice != m_hovered_slice) {
215+
m_hovered_slice = slice;
216+
update();
217+
}
218+
QWidget::mouseMoveEvent(event);
219+
}
220+
221+
void DonutChart::leaveEvent(QEvent* event)
222+
{
223+
if (m_hovered_slice != -1) {
224+
m_hovered_slice = -1;
225+
update();
226+
}
227+
QWidget::leaveEvent(event);
228+
}

src/qt/donutchart.h

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// Copyright (c) 2026 The Dash Core developers
2+
// Distributed under the MIT software license, see the accompanying
3+
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
4+
5+
#ifndef BITCOIN_QT_DONUTCHART_H
6+
#define BITCOIN_QT_DONUTCHART_H
7+
8+
#include <QWidget>
9+
10+
#include <vector>
11+
12+
class DonutChart : public QWidget
13+
{
14+
Q_OBJECT
15+
16+
public:
17+
struct Slice {
18+
double m_value{0.0};
19+
QColor m_color;
20+
QString m_donut_center_label;
21+
QString m_donut_sub_label1;
22+
QString m_donut_sub_label2;
23+
};
24+
25+
struct CenterText {
26+
QString m_donut_center_label;
27+
QString m_donut_sub_label1;
28+
QString m_donut_sub_label2;
29+
};
30+
31+
explicit DonutChart(QWidget* parent = nullptr);
32+
~DonutChart() override;
33+
34+
void setData(std::vector<Slice>&& slices, double total_capacity, CenterText&& default_text);
35+
void clear();
36+
37+
QSize sizeHint() const override;
38+
QSize minimumSizeHint() const override;
39+
40+
protected:
41+
void paintEvent(QPaintEvent* event) override;
42+
void mouseMoveEvent(QMouseEvent* event) override;
43+
void leaveEvent(QEvent* event) override;
44+
45+
private:
46+
struct Geometry {
47+
int m_inner_radius;
48+
int m_outer_radius;
49+
QPoint m_center;
50+
};
51+
52+
Geometry chartGeometry() const;
53+
int sliceAtPosition(const QPoint& pos) const;
54+
55+
private:
56+
CenterText m_default_center_text;
57+
double m_total_capacity{0};
58+
int m_hovered_slice{-1};
59+
std::vector<Slice> m_slices;
60+
};
61+
62+
#endif // BITCOIN_QT_DONUTCHART_H

test/util/data/non-backported.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ src/messagesigner.*
3030
src/netfulfilledman.*
3131
src/qt/clientfeeds.*
3232
src/qt/descriptiondialog.*
33+
src/qt/donutchart.*
3334
src/qt/guiutil_font.*
3435
src/qt/informationwidget.*
3536
src/qt/masternodelist.*

0 commit comments

Comments
 (0)