1 | /* |
2 | * SPDX-FileCopyrightText: 2020 Arjen Hiemstra <ahiemstra@heimr.nl> |
3 | * |
4 | * SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL |
5 | */ |
6 | |
7 | #include "LegendLayout.h" |
8 | |
9 | #include <cmath> |
10 | |
11 | #include "Chart.h" |
12 | #include "ItemBuilder.h" |
13 | #include "datasource/ChartDataSource.h" |
14 | |
15 | qreal sizeWithSpacing(int count, qreal size, qreal spacing) |
16 | { |
17 | return size * count + spacing * (count - 1); |
18 | } |
19 | |
20 | LegendLayoutAttached::LegendLayoutAttached(QObject *parent) |
21 | : QObject(parent) |
22 | { |
23 | } |
24 | |
25 | qreal LegendLayoutAttached::minimumWidth() const |
26 | { |
27 | return m_minimumWidth.value_or(u: 0.0); |
28 | } |
29 | |
30 | void LegendLayoutAttached::setMinimumWidth(qreal newMinimumWidth) |
31 | { |
32 | if (newMinimumWidth == m_minimumWidth) { |
33 | return; |
34 | } |
35 | |
36 | m_minimumWidth = newMinimumWidth; |
37 | Q_EMIT minimumWidthChanged(); |
38 | } |
39 | |
40 | bool LegendLayoutAttached::isMinimumWidthValid() const |
41 | { |
42 | return m_minimumWidth.has_value(); |
43 | } |
44 | |
45 | qreal LegendLayoutAttached::preferredWidth() const |
46 | { |
47 | return m_preferredWidth.value_or(u: 0.0); |
48 | } |
49 | |
50 | void LegendLayoutAttached::setPreferredWidth(qreal newPreferredWidth) |
51 | { |
52 | if (newPreferredWidth == m_preferredWidth) { |
53 | return; |
54 | } |
55 | |
56 | m_preferredWidth = newPreferredWidth; |
57 | Q_EMIT preferredWidthChanged(); |
58 | } |
59 | |
60 | bool LegendLayoutAttached::isPreferredWidthValid() const |
61 | { |
62 | return m_preferredWidth.has_value(); |
63 | } |
64 | |
65 | qreal LegendLayoutAttached::maximumWidth() const |
66 | { |
67 | return m_maximumWidth.value_or(u: 0.0); |
68 | } |
69 | |
70 | void LegendLayoutAttached::setMaximumWidth(qreal newMaximumWidth) |
71 | { |
72 | if (newMaximumWidth == m_maximumWidth) { |
73 | return; |
74 | } |
75 | |
76 | m_maximumWidth = newMaximumWidth; |
77 | Q_EMIT maximumWidthChanged(); |
78 | } |
79 | |
80 | bool LegendLayoutAttached::isMaximumWidthValid() const |
81 | { |
82 | return m_maximumWidth.has_value(); |
83 | } |
84 | |
85 | LegendLayout::LegendLayout(QQuickItem *parent) |
86 | : QQuickItem(parent) |
87 | { |
88 | } |
89 | |
90 | qreal LegendLayout::horizontalSpacing() const |
91 | { |
92 | return m_horizontalSpacing; |
93 | } |
94 | |
95 | void LegendLayout::setHorizontalSpacing(qreal newHorizontalSpacing) |
96 | { |
97 | if (newHorizontalSpacing == m_horizontalSpacing) { |
98 | return; |
99 | } |
100 | |
101 | m_horizontalSpacing = newHorizontalSpacing; |
102 | polish(); |
103 | Q_EMIT horizontalSpacingChanged(); |
104 | } |
105 | |
106 | qreal LegendLayout::verticalSpacing() const |
107 | { |
108 | return m_verticalSpacing; |
109 | } |
110 | |
111 | void LegendLayout::setVerticalSpacing(qreal newVerticalSpacing) |
112 | { |
113 | if (newVerticalSpacing == m_verticalSpacing) { |
114 | return; |
115 | } |
116 | |
117 | m_verticalSpacing = newVerticalSpacing; |
118 | polish(); |
119 | Q_EMIT verticalSpacingChanged(); |
120 | } |
121 | |
122 | qreal LegendLayout::preferredWidth() const |
123 | { |
124 | return m_preferredWidth; |
125 | } |
126 | |
127 | void LegendLayout::componentComplete() |
128 | { |
129 | QQuickItem::componentComplete(); |
130 | |
131 | m_completed = true; |
132 | polish(); |
133 | } |
134 | |
135 | void LegendLayout::updatePolish() |
136 | { |
137 | if (!m_completed) { |
138 | return; |
139 | } |
140 | |
141 | int columns = 0; |
142 | int rows = 0; |
143 | qreal itemWidth = 0.0; |
144 | qreal itemHeight = 0.0; |
145 | |
146 | qreal layoutWidth = width(); |
147 | |
148 | std::tie(args&: columns, args&: rows, args&: itemWidth, args&: itemHeight) = determineColumns(); |
149 | |
150 | auto column = 0; |
151 | auto row = 0; |
152 | |
153 | const auto items = childItems(); |
154 | for (auto item : items) { |
155 | if (!item->isVisible() || item->implicitWidth() <= 0 || item->implicitHeight() <= 0) { |
156 | continue; |
157 | } |
158 | |
159 | auto attached = static_cast<LegendLayoutAttached *>(qmlAttachedPropertiesObject<LegendLayout>(obj: item, create: true)); |
160 | |
161 | auto x = (itemWidth + m_horizontalSpacing) * column; |
162 | auto y = (itemHeight + m_verticalSpacing) * row; |
163 | |
164 | item->setPosition(QPointF{x, y}); |
165 | item->setWidth(std::clamp(val: itemWidth, lo: attached->minimumWidth(), hi: attached->maximumWidth())); |
166 | |
167 | // If we are in single column mode, we are most likely width constrained. |
168 | // In that case, we should make sure items do not exceed our own width, |
169 | // so we can trigger things like text eliding. |
170 | if (layoutWidth > 0 && item->width() > layoutWidth && columns == 1) { |
171 | item->setWidth(layoutWidth); |
172 | } |
173 | |
174 | column++; |
175 | if (column >= columns) { |
176 | row++; |
177 | column = 0; |
178 | } |
179 | } |
180 | |
181 | setImplicitSize(sizeWithSpacing(count: columns, size: itemWidth, spacing: m_horizontalSpacing), sizeWithSpacing(count: rows, size: itemHeight, spacing: m_verticalSpacing)); |
182 | } |
183 | |
184 | void LegendLayout::geometryChange(const QRectF &newGeometry, const QRectF &oldGeometry) |
185 | { |
186 | if (newGeometry != oldGeometry) { |
187 | polish(); |
188 | } |
189 | QQuickItem::geometryChange(newGeometry, oldGeometry); |
190 | } |
191 | |
192 | void LegendLayout::itemChange(QQuickItem::ItemChange change, const QQuickItem::ItemChangeData &data) |
193 | { |
194 | if (change == QQuickItem::ItemVisibleHasChanged || change == QQuickItem::ItemSceneChange) { |
195 | polish(); |
196 | } |
197 | |
198 | if (change == QQuickItem::ItemChildAddedChange) { |
199 | auto item = data.item; |
200 | |
201 | connect(sender: item, signal: &QQuickItem::implicitWidthChanged, context: this, slot: &LegendLayout::polish); |
202 | connect(sender: item, signal: &QQuickItem::implicitHeightChanged, context: this, slot: &LegendLayout::polish); |
203 | connect(sender: item, signal: &QQuickItem::visibleChanged, context: this, slot: &LegendLayout::polish); |
204 | |
205 | auto attached = static_cast<LegendLayoutAttached *>(qmlAttachedPropertiesObject<LegendLayout>(obj: item, create: true)); |
206 | connect(sender: attached, signal: &LegendLayoutAttached::minimumWidthChanged, context: this, slot: &LegendLayout::polish); |
207 | connect(sender: attached, signal: &LegendLayoutAttached::preferredWidthChanged, context: this, slot: &LegendLayout::polish); |
208 | connect(sender: attached, signal: &LegendLayoutAttached::maximumWidthChanged, context: this, slot: &LegendLayout::polish); |
209 | |
210 | polish(); |
211 | } |
212 | |
213 | if (change == QQuickItem::ItemChildRemovedChange) { |
214 | auto item = data.item; |
215 | |
216 | item->disconnect(receiver: this); |
217 | auto attached = static_cast<LegendLayoutAttached *>(qmlAttachedPropertiesObject<LegendLayout>(obj: item, create: false)); |
218 | if (attached) { |
219 | attached->disconnect(receiver: this); |
220 | } |
221 | |
222 | polish(); |
223 | } |
224 | |
225 | QQuickItem::itemChange(change, data); |
226 | } |
227 | |
228 | // Determine how many columns and rows should be used for placing items and how |
229 | // large each item should be. |
230 | std::tuple<int, int, qreal, qreal> LegendLayout::determineColumns() |
231 | { |
232 | auto minWidth = -std::numeric_limits<qreal>::max(); |
233 | auto preferredWidth = -std::numeric_limits<qreal>::max(); |
234 | auto maxWidth = std::numeric_limits<qreal>::max(); |
235 | auto maxHeight = -std::numeric_limits<qreal>::max(); |
236 | |
237 | const auto items = childItems(); |
238 | |
239 | // Keep track of actual visual and visible items, since childItems() also |
240 | // includes stuff like repeaters. |
241 | auto itemCount = 0; |
242 | |
243 | // First, we determine the minimum, preferred and maximum width of all |
244 | // items. These are determined from the attached object, or implicitWidth |
245 | // for minimum size if minimumWidth has not been set. |
246 | // |
247 | // We also determine the maximum height of items so we do not need to do |
248 | // that later. |
249 | for (auto item : items) { |
250 | if (!item->isVisible() || item->implicitWidth() <= 0 || item->implicitHeight() <= 0) { |
251 | continue; |
252 | } |
253 | |
254 | auto attached = static_cast<LegendLayoutAttached *>(qmlAttachedPropertiesObject<LegendLayout>(obj: item, create: true)); |
255 | |
256 | if (attached->isMinimumWidthValid()) { |
257 | minWidth = std::max(a: minWidth, b: attached->minimumWidth()); |
258 | } else { |
259 | minWidth = std::max(a: minWidth, b: item->implicitWidth()); |
260 | } |
261 | |
262 | if (attached->isPreferredWidthValid()) { |
263 | preferredWidth = std::max(a: preferredWidth, b: attached->preferredWidth()); |
264 | } |
265 | |
266 | if (attached->isMaximumWidthValid()) { |
267 | maxWidth = std::min(a: maxWidth, b: attached->maximumWidth()); |
268 | } |
269 | |
270 | maxHeight = std::max(a: maxHeight, b: item->implicitHeight()); |
271 | |
272 | itemCount++; |
273 | } |
274 | |
275 | if (itemCount == 0) { |
276 | return std::make_tuple(args: 0, args: 0, args: 0, args: 0); |
277 | } |
278 | |
279 | auto availableWidth = width(); |
280 | // Check if we have a valid width. If we cannot even fit a horizontalSpacing |
281 | // we cannot do anything with the width and most likely did not get a width |
282 | // assigned, so come up with some reasonable default width. |
283 | // |
284 | // For the default, layout everything in a full row, using either maxWidth |
285 | // for each item if we have it or minWidth if we do not. |
286 | if (availableWidth <= m_horizontalSpacing) { |
287 | if (maxWidth <= 0.0) { |
288 | availableWidth = sizeWithSpacing(count: itemCount, size: minWidth, spacing: m_horizontalSpacing); |
289 | } else { |
290 | availableWidth = sizeWithSpacing(count: itemCount, size: maxWidth, spacing: m_horizontalSpacing); |
291 | } |
292 | } |
293 | |
294 | // If none of the items have a maximum width set, default to filling all |
295 | // available space. |
296 | if (maxWidth <= 0.0 || maxWidth >= std::numeric_limits<qreal>::max()) { |
297 | maxWidth = availableWidth; |
298 | } |
299 | |
300 | // Ensure we don't try to size things below their minimum size. |
301 | if (maxWidth < minWidth) { |
302 | maxWidth = minWidth; |
303 | } |
304 | |
305 | if (preferredWidth != m_preferredWidth) { |
306 | m_preferredWidth = preferredWidth; |
307 | Q_EMIT preferredWidthChanged(); |
308 | } |
309 | |
310 | auto columns = 1; |
311 | auto rows = itemCount; |
312 | bool fit = true; |
313 | |
314 | // Calculate the actual number of rows and columns by trying to fit items |
315 | // until we find the right number. |
316 | while (true) { |
317 | auto minTotalWidth = sizeWithSpacing(count: columns, size: minWidth, spacing: m_horizontalSpacing); |
318 | auto maxTotalWidth = sizeWithSpacing(count: columns, size: maxWidth, spacing: m_horizontalSpacing); |
319 | |
320 | // If the minimum width is less than our width, but the maximum is |
321 | // larger, we found a correct solution since we can resize the items to |
322 | // fit within the provided bounds. |
323 | if (minTotalWidth <= availableWidth && maxTotalWidth >= availableWidth) { |
324 | break; |
325 | } |
326 | |
327 | // As long as we have more space available than the items' max size, |
328 | // decrease the number of rows and that way increase the number of |
329 | // columns we use to place items - unless that results in no rows, as |
330 | // that means we've reached a state where we simply have more space than |
331 | // needed. |
332 | if (maxTotalWidth < availableWidth) { |
333 | rows--; |
334 | if (rows >= 1) { |
335 | columns = std::ceil(x: itemCount / float(rows)); |
336 | } else { |
337 | fit = false; |
338 | break; |
339 | } |
340 | } |
341 | |
342 | // In certain cases, we hit a corner case where decreasing the number of |
343 | // rows leads to things ending up outside of the item's bounds. If that |
344 | // happens, increase the number of rows by one and exit the loop. |
345 | if (minTotalWidth > availableWidth) { |
346 | rows += 1; |
347 | columns = std::ceil(x: itemCount / float(rows)); |
348 | break; |
349 | } |
350 | } |
351 | |
352 | // Calculate item width based on the calculated number of columns. |
353 | // If it turns out we have more space than needed, use maxWidth |
354 | // instead to avoid awkward gaps. |
355 | auto itemWidth = fit ? (availableWidth - m_horizontalSpacing * (columns - 1)) / columns : maxWidth; |
356 | |
357 | // Recalculate the number of rows, otherwise we may end up with "ghost" rows |
358 | // since the items wrapped into a new column, but no all of them. |
359 | rows = std::ceil(x: itemCount / float(columns)); |
360 | |
361 | return std::make_tuple(args&: columns, args&: rows, args&: itemWidth, args&: maxHeight); |
362 | } |
363 | |
364 | #include "moc_LegendLayout.cpp" |
365 | |