Lomiri
Loading...
Searching...
No Matches
PanelItemRow.qml
1/*
2 * Copyright (C) 2013-2014 Canonical Ltd.
3 * Copyright (C) 2020-2026 UBports Foundation
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; version 3.
8 *
9 * This program is distributed in the hope that it will be useful,
10 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 * GNU General Public License for more details.
13 *
14 * You should have received a copy of the GNU General Public License
15 * along with this program. If not, see <http://www.gnu.org/licenses/>.
16 */
17
18import QtQuick 2.15
19import Lomiri.Components 1.3
20import Utils 0.1
21import "../../.."
22import "../../../Components"
23
24Item {
25 id: root
26 implicitHeight: units.gu(3)
27
28 property int align: Qt.AlignRight
29 property int orientation: Qt.Horizontal
30 property bool clip: true
31 property alias interactive: flickable.interactive
32 property var collapsedCutouts: []
33 property var expandedCutouts: []
34 property real collapsedHeight: units.gu(3)
35 property bool hideRow: false
36 property QtObject model: null
37 property real overFlowWidth: width
38 property bool expanded: false
39 property bool finishedExpanding: false
40 property var screenOrientation: Qt.PrimaryOrientation
41
42 property var currentItem: null
43 property int currentItemIndex: 0
44 property real unitProgress: 0.0
45 property real selectionChangeBuffer: units.gu(2)
46 property bool enableLateralChanges: false
47
48 property real lastSelectedX: 0.0
49 property alias delegate: repeater.delegate
50 property alias contentX: flickable.contentX
51 property alias contentWidth: flickable.contentWidth
52
53 property real lateralPosition: -1
54 property int screenIndex: -1
55
56 DisplayCutoutsModel {
57 id: cutoutsModel
58 expanded: root.expanded
59 orientation: screenOrientation
60 enabled: screenIndex === 0
61 lightMode: theme.palette.normal.background.hslLightness >= 0.5
62
63 onModelReset: {
64 d.recalculateItems();
65 }
66 }
67
68 Connections {
69 target: DebuggingController
70
71 function onDeviceInfoReloadRequested() {
72 cutoutsModel.reloadConfig();
73 }
74 }
75
76 onCurrentItemIndexChanged: {
77 currentItem = repeater.itemAt(currentItemIndex);
78 d.recalculateItems();
79 }
80
81 onHeightChanged: {
82 d.recalculateItems();
83 }
84
85 onWidthChanged: {
86 d.recalculateItems();
87 }
88
89 onFinishedExpandingChanged: {
90 if (!expanded) {
91 flickable.contentX = 0;
92 }
93 d.recalculateItems();
94 }
95
96 onLateralPositionChanged: {
97 updateItemFromLateralPosition();
98 }
99
100 onEnableLateralChangesChanged: {
101 updateItemFromLateralPosition();
102 }
103
104 Connections {
105 target: model
106 function onCountChanged() {
107 d.recalculateItems();
108 }
109 }
110
111 function updateItemFromLateralPosition() {
112 if (!enableLateralChanges || !root.finishedExpanding || lateralPosition === -1)
113 return;
114
115 selectItemAt(lateralPosition);
116 }
117
118 function indicatorAt(x, y) {
119 const pos = flickable.contentItem.mapFromItem(root, x, y);
120 const indicator = flickable.contentItem.childAt(pos.x, pos.y);
121
122 if (indicator) {
123 return indicator;
124 }
125
126 // Backwards compatibility, consumers of this function don't expect holes
127 let closestBefore = null;
128 let closestAfter = null;
129 for (let i = 0; i < repeater.count; i++) {
130 const item = repeater.itemAt(d.isFlipped ? i : repeater.count - i - 1);
131 if (item.x > pos.x) {
132 closestBefore = item;
133 closestAfter = repeater.itemAt(i + 1);
134 }
135 }
136 if (!closestBefore) {
137 return null;
138 }
139 if (!closestAfter || closestBefore.x + closestBefore.width - pos.x < pos.x - closestAfter.x) {
140 return closestBefore;
141 }
142
143 return closestAfter;
144 }
145
146 function resetCurrentItem() {
147 flickable.contentX = 0;
148 root.lastSelectedX = 0.0;
149 root.currentItemIndex = -1;
150 }
151
152 function selectPreviousItem() {
153 let indexToSelect = currentItemIndex - 1;
154 while (indexToSelect >= 0) {
155 if (setCurrentItemIndex(indexToSelect)) {
156 return;
157 }
158 indexToSelect -= 1;
159 }
160 }
161
162 function selectNextItem() {
163 let indexToSelect = currentItemIndex + 1;
164 while (indexToSelect < repeater.count) {
165 if (setCurrentItemIndex(indexToSelect)) {
166 return;
167 }
168 indexToSelect += 1;
169 }
170 }
171
172 function setCurrentItemIndex(index) {
173 for (let i = 0; i < repeater.count; i++) {
174 const item = repeater.itemAt(i);
175 if (item.hasOwnProperty('ownIndex') && item.ownIndex === index && item.enabled) {
176 root.currentItemIndex = index;
177 return true;
178 }
179 }
180
181 return false;
182 }
183
184 function selectItemAt(x) {
185 root.lastSelectedX = x;
186 const item = indicatorAt(x, flickable.height / 2);
187 if (item && item.opacity > 0 && item.enabled) {
188 root.currentItemIndex = item.ownIndex;
189 } else {
190 const searchIndex = x < width / 2 ? 0 : repeater.count - 1;
191 setCurrentItemIndex(searchIndex);
192 }
193 }
194
195 QtObject {
196 id: d
197
198 property bool isFlipped: {
199 root.align === Qt.AlignRight && root.orientation === Qt.Horizontal || root.align === Qt.AlignBottom && root.orientation === Qt.Vertical;
200 }
201 property bool isHorizontal: root.orientation === Qt.Horizontal
202
203 function recalculateLater() {
204 Qt.callLater(recalculateItems);
205 }
206
207 function recalculateItems() {
208 const sizeField = isHorizontal ? 'width' : 'height';
209 const posField = isHorizontal ? 'x' : 'y';
210 const flickableField = isHorizontal ? 'contentX' : 'contentY';
211 let currentX = 0;
212 let screenX = 0;
213 let itemMaxSize = 0;
214
215 if (!root.finishedExpanding && root.currentItemIndex >= 0) {
216 const mapped = flickable.mapToItem(root, root.lastSelectedX, 0);
217 let sumBefore = 0;
218 for (let i = 0; i < repeater.count; i++) {
219 if ((d.isFlipped && i >= root.currentItemIndex) || (!d.isFlipped && i < root.currentItemIndex)) {
220 sumBefore += repeater.itemAt(i)[sizeField];
221 }
222 }
223 for (let i = 0; i < cutoutsModel.rowCount(); i++) {
224 const cutout = cutoutsModel.data(cutoutsModel.index(i, 0));
225 if ((d.isFlipped && cutout[posField] + cutout[sizeField] > root.lastSelectedX) || (!d.isFlipped && cutout[posField] < root.lastSelectedX)) {
226 sumBefore += cutout[sizeField];
227 }
228 }
229
230 if (root.lastSelectedX > 0) {
231 flickable[flickableField] = sumBefore - mapped.x;
232 }
233 }
234
235 for (let i = 0; i < repeater.count; i++) {
236 const item = repeater.itemAt(d.isFlipped ? repeater.count - 1 - i : i);
237 const itemSize = Math.ceil(item[sizeField]);
238
239 if (itemSize > itemMaxSize) {
240 itemMaxSize = itemSize;
241 }
242
243 screenX = currentX - flickable[flickableField];
244
245 for (let ci = 0; ci < cutoutsModel.rowCount(); ci++) {
246 const cutout = cutoutsModel.data(cutoutsModel.index(ci, 0));
247 const cutoutSize = Math.floor(cutout[sizeField]);
248 const cutoutPos = Math.floor(d.isFlipped ? root[sizeField] - cutout[posField] - cutoutSize : cutout[posField]);
249
250 if (screenX > cutoutPos + cutoutSize || screenX + itemSize <= cutoutPos) {
251 continue;
252 }
253
254 screenX = cutoutPos + cutoutSize;
255 currentX = screenX + flickable[flickableField];
256 }
257
258 item[posField] = currentX;
259 item.rotation = d.isFlipped ? -180 : 0;
260 currentX += itemSize;
261 }
262
263 const lastCutout = cutoutsModel.data(cutoutsModel.index(cutoutsModel.rowCount() - 1, 0));
264 const lastSectionSize = Math.floor(root[sizeField] - (lastCutout ? lastCutout[posField] - lastCutout[sizeField] : 0));
265 const lastItemSize = repeater.itemAt(repeater.count - 1) ? Math.ceil(repeater.itemAt(repeater.count - 1)[sizeField]) : 0;
266 let cutoutsSize = 0;
267 for (let i = 0; i < cutoutsModel.rowCount(); i++) {
268 const cutout = cutoutsModel.data(cutoutsModel.index(i, 0));
269 cutoutsSize += cutout[sizeField];
270 }
271 if (lastSectionSize < lastItemSize) {
272 // TODO: this is not the proper solution
273 // with this, you can slide the last item too far in the direction of the alignment
274 // but otherwise the last item will be forced outside the bounds
275 currentX += cutoutsSize;
276 }
277 flickable[isHorizontal ? 'contentWidth' : 'contentHeight'] = currentX;
278 updateItemFromLateralPosition();
279 }
280 }
281
282 Flickable {
283 id: flickable
284 pixelAligned: true
285 anchors.fill: root
286 interactive: false
287 flickableDirection: d.isHorizontal ? Flickable.HorizontalFlick : Flickable.VerticalFlick
288 rotation: d.isFlipped ? 180 : 0
289
290 Behavior on contentX {
291 enabled: !enableLateralChanges && root.finishedExpanding
292 NumberAnimation {
293 duration: LomiriAnimation.BriskDuration
294 easing: LomiriAnimation.StandardEasing
295 }
296 }
297
298 onContentXChanged: {
299 d.recalculateItems();
300 }
301 onContentYChanged: {
302 d.recalculateItems();
303 }
304 onContentWidthChanged: {
305 if (!interactive) {
306 d.recalculateLater();
307 }
308 }
309
310 Repeater {
311 id: repeater
312 model: root.model
313 objectName: "panelRow"
314
315 onItemAdded: {
316 item.widthChanged.connect(d.recalculateItems);
317 }
318 }
319 }
320
321 Rectangle {
322 id: highlight
323 objectName: "highlight"
324
325 anchors {
326 top: root.top
327 topMargin: flickable.height - height
328 }
329 height: units.dp(2)
330 color: theme.palette.normal.foregroundText
331 visible: currentItem !== null
332 opacity: 0.0
333
334 width: currentItem ? currentItem.width : 0
335 Behavior on width {
336 enabled: enableLateralChanges && expanded
337 LomiriNumberAnimation {
338 duration: LomiriAnimation.FastDuration
339 easing: LomiriAnimation.StandardEasing
340 }
341 }
342
343 // micromovements of the highlight line when user moves the finger across the items while pulling
344 // the handle downwards.
345 property real highlightCenterOffset: {
346 if (!currentItem || lateralPosition == -1 || !enableLateralChanges)
347 return 0;
348
349 var itemMapped = root.mapToItem(currentItem, lateralPosition, 0);
350
351 var distanceFromCenter = itemMapped.x - currentItem.width / 2;
352 if (distanceFromCenter > 0) {
353 distanceFromCenter = Math.max(0, distanceFromCenter - currentItem.width / 8);
354 } else {
355 distanceFromCenter = Math.min(0, distanceFromCenter + currentItem.width / 8);
356 }
357
358 if (currentItem && currentItem.ownIndex === 0 && distanceFromCenter < 0) {
359 return 0;
360 } else if (currentItem && currentItem.ownIndex === repeater.count - 1 & distanceFromCenter > 0) {
361 return 0;
362 }
363 return (distanceFromCenter / (currentItem.width / 4)) * units.gu(1);
364 }
365
366 Behavior on highlightCenterOffset {
367 NumberAnimation {
368 duration: LomiriAnimation.FastDuration
369 easing: LomiriAnimation.StandardEasing
370 }
371 }
372
373 // FIXME: flickable.contentX != null is only there to trigger the binding because currentItem.mapToItem won't
374 property real currentItemX: currentItem && flickable.contentX != null ? currentItem.mapToItem(root, 0, 0).x : 0
375
376 Behavior on currentItemX {
377 id: currentItemXBehavior
378 enabled: enableLateralChanges && expanded && !flickable.moving && root.finishedExpanding
379 NumberAnimation {
380 duration: LomiriAnimation.FastDuration
381 easing: LomiriAnimation.StandardEasing
382 }
383 }
384 x: currentItemX + highlightCenterOffset
385 }
386
387 states: [
388 State {
389 name: "minimised"
390 when: !expanded
391 },
392 State {
393 name: "expanded"
394 when: expanded
395 PropertyChanges { target: highlight; opacity: 0.9 }
396 }
397 ]
398
399 transitions: [
400 Transition {
401 PropertyAnimation {
402 properties: "opacity";
403 duration: LomiriAnimation.SnapDuration
404 easing: LomiriAnimation.StandardEasing
405 }
406 }
407 ]
408}