FormLayout.qml 17.2 KB
Newer Older
Marco Martin's avatar
Marco Martin committed
1
/*
2
 *  SPDX-FileCopyrightText: 2017 Marco Martin <mart@kde.org>
Marco Martin's avatar
Marco Martin committed
3
 *
4
 *  SPDX-License-Identifier: LGPL-2.0-or-later
Marco Martin's avatar
Marco Martin committed
5
6
7
 */

import QtQuick 2.6
8
9
import QtQuick.Layouts 1.2
import QtQuick.Controls 2.2
Marco Martin's avatar
Marco Martin committed
10
import org.kde.kirigami 2.4 as Kirigami
Marco Martin's avatar
Marco Martin committed
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

/**
 * This is the base class for Form layouts conforming to the
 * Kirigami Human interface guidelines. The layout will
 * be divided in two columns: on the right there will be a column
 * of fields, on the left their labels specified in the FormData attached
 * property.
 *
 * Example:
 * @code
 * import org.kde.kirigami 2.3 as Kirigami
 * Kirigami.FormLayout {
 *    TextField {
 *       Kirigami.FormData.label: "Label:"
 *    }
 *    Kirigami.Separator {
 *        Kirigami.FormData.label: "Section Title"
 *        Kirigami.FormData.isSection: true
 *    }
 *    TextField {
 *       Kirigami.FormData.label: "Label:"
 *    }
 *    TextField {
 *    }
 * }
 * @endcode
37
 * @inherit QtQuick.Item
Marco Martin's avatar
Marco Martin committed
38
39
 * @since 2.3
 */
40
41
42
43
44
45
46
47
48
Item {
    id: root

    /**
     * wideMode: bool
     * If true the layout will be optimized for a wide screen, such as
     * a desktop machine (the labels will be on a left column,
     * the fields on a right column beside it), if false (such as on a phone)
     * everything is laid out in a single column.
Yuri Chornoivan's avatar
Yuri Chornoivan committed
49
     * by default this will be based on whether the application is
50
     * wide enough for the layout of being in such mode.
Yuri Chornoivan's avatar
Yuri Chornoivan committed
51
     * It can be overridden by reassigning the property
52
53
54
     */
    property bool wideMode: width >= lay.wideImplicitWidth

55
    implicitWidth: lay.wideImplicitWidth
56
57
    implicitHeight: lay.implicitHeight
    Layout.preferredHeight: lay.implicitHeight
58
    Accessible.role: Accessible.Form
59

60
61
62
63
    Component.onCompleted: {
        relayoutTimer.triggered()
    }

Marco Martin's avatar
Marco Martin committed
64
65
    /**
     * twinFormLayouts: list<FormLayout>
Harald Sitter's avatar
Harald Sitter committed
66
     * If for some implementation reason multiple FormLayouts have to appear
Marco Martin's avatar
Marco Martin committed
67
68
69
70
71
72
73
74
75
     * on the same page, they can have each other in twinFormLayouts,
     * so they will vertically align each other perfectly
     * @since 5.53
     */
    //should be list<FormLayout> but we can't have a recursive declaration
    property list<Item> twinFormLayouts

    Layout.fillWidth: true

76
77
78
79
80
81
82
83
84
85
86
87
88
89
    onTwinFormLayoutsChanged: {
        for (let i in twinFormLayouts) {
            if (!(root in twinFormLayouts[i].children[0].reverseTwins)) {
                twinFormLayouts[i].children[0].reverseTwins.push(root)
                Qt.callLater(() => twinFormLayouts[i].children[0].reverseTwinsChanged());
            }
        }
    }

    Component.onDestruction: {
        for (let i in twinFormLayouts) {
            twinFormLayouts[i].children[0].reverseTwins = twinFormLayouts[i].children[0].reverseTwins.filter(function(value, index, arr){ return value != root;})
        }
    }
90
91
92
93
94
95
    GridLayout {
        id: lay
        property int wideImplicitWidth
        columns: root.wideMode ? 2 : 1
        rowSpacing: Kirigami.Units.smallSpacing
        columnSpacing: Kirigami.Units.smallSpacing
96
97

        property var reverseTwins: []
98
        property var knownItems: []
Marco Martin's avatar
Marco Martin committed
99
100
        property var buddies: []
        property int knownItemsImplicitWidth: {
Janet Blackquill's avatar
Janet Blackquill committed
101
102
            var hint = 0;
            for (var i in knownItems) {
103
104
105
106
107
108
109
110
                let actualWidth = knownItems[i].implicitWidth
                if (knownItems[i].Layout.preferredWidth > 0) {
                    actualWidth = knownItems[i].Layout.preferredWidth
                }
                actualWidth = Math.min(actualWidth, knownItems[i].Layout.maximumWidth)
                actualWidth = Math.max(actualWidth, knownItems[i].Layout.minimumWidth)

                hint = Math.max(hint, actualWidth);
Janet Blackquill's avatar
Janet Blackquill committed
111
112
            }
            return hint;
Marco Martin's avatar
Marco Martin committed
113
114
        }
        property int buddiesImplicitWidth: {
Janet Blackquill's avatar
Janet Blackquill committed
115
116
            var hint = 0;
            for (var i in buddies) {
117
                if (buddies[i].visible && !buddies[i].item.Kirigami.FormData.isSection) {
Janet Blackquill's avatar
Janet Blackquill committed
118
119
120
121
                    hint = Math.max(hint, buddies[i].implicitWidth);
                }
            }
            return hint;
Marco Martin's avatar
Marco Martin committed
122
        }
123
        readonly property var actualTwinFormLayouts: {
124
125
            // We need to copy that array by value
            let list = lay.reverseTwins.slice();
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
            for (let i in twinFormLayouts) {
                let parentLay = twinFormLayouts[i];
                if (!parentLay || !parentLay.hasOwnProperty("children")) {
                    continue;
                }
                list.push(parentLay);
                for (let j in parentLay.children[0].reverseTwins) {
                    let childLay = parentLay.children[0].reverseTwins[j];
                    if (childLay && !(childLay in list)) {
                        list.push(childLay);
                    }
                }
            }
            return list;
        }

142
143
144
145
146
147
148
149
150
151
152
        states: [
            State {
                when: root.wideMode
                AnchorChanges {
                    target: lay
                    anchors {
                        left: undefined
                        right: undefined
                        horizontalCenter: root.horizontalCenter
                    }
                }
153
154
155
156
                PropertyChanges {
                    target: lay
                    width: undefined
                }
157
158
159
160
161
162
163
164
165
166
167
            },
            State {
                when: !root.wideMode
                AnchorChanges {
                    target: lay
                    anchors {
                        left: parent.left
                        right: parent.right
                        horizontalCenter: undefined
                    }
                }
168
169
170
171
172
                PropertyChanges {
                    target: lay
                    implicitWidth: root.width
                    width: Math.min(implicitWidth, parent.width)
                }
173

174
175
176
            }
        ]

Marco Martin's avatar
Marco Martin committed
177
        width: Math.min(implicitWidth, parent.width)
178
179
        Timer {
            id: hintCompression
180
            interval: 0
181
182
183
184
185
186
187
            onTriggered: {
                if (root.wideMode) {
                    lay.wideImplicitWidth = lay.implicitWidth;
                }
            }
        }
        onImplicitWidthChanged: hintCompression.restart();
Marco Martin's avatar
Marco Martin committed
188
        //This invisible row is used to sync alignment between multiple layouts
189

Marco Martin's avatar
Marco Martin committed
190
191
        Item {
            Layout.preferredWidth: {
192
193
194
195
196
                var hint = lay.buddiesImplicitWidth;
                for (var i in lay.actualTwinFormLayouts) {
                    if (lay.actualTwinFormLayouts[i] && lay.actualTwinFormLayouts[i].hasOwnProperty("children")) {
                        hint = Math.max(hint, lay.actualTwinFormLayouts[i].children[0].buddiesImplicitWidth);
                    }
Janet Blackquill's avatar
Janet Blackquill committed
197
                }
Marco Martin's avatar
Marco Martin committed
198
199
                return hint;
            }
200
            Layout.preferredHeight:2
Marco Martin's avatar
Marco Martin committed
201
202
203
        }
        Item {
            Layout.preferredWidth: {
204
                var hint = Math.min(root.width, lay.knownItemsImplicitWidth);
205
206
207
208
                for (var i in lay.actualTwinFormLayouts) {
                    if (lay.actualTwinFormLayouts[i] && lay.actualTwinFormLayouts[i].hasOwnProperty("children")) {
                        hint = Math.max(hint, lay.actualTwinFormLayouts[i].children[0].knownItemsImplicitWidth);
                    }
Janet Blackquill's avatar
Janet Blackquill committed
209
                }
Marco Martin's avatar
Marco Martin committed
210
211
                return hint;
            }
212
            Layout.preferredHeight:2
Marco Martin's avatar
Marco Martin committed
213
        }
214
215
216
217
    }

    Item {
        id: temp
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263

        /**
         * The following two functions are used in the label buddy items.
         *
         * They're in this mostly unused item to keep them private to the FormLayout
         * without creating another QObject.
         *
         * Normally, such complex things in bindings are kinda bad for performance
         * but this is a fairly static property. If for some reason an application
         * decides to obsessively change its alignment, V8's JIT hotspot optimisations
         * will kick in.
         */

        /**
         * @param {Item} item
         *
         * @returns {number}
         */
        function effectiveLayout(item) {
            let verticalAlignment =
                item.Kirigami.FormData.labelAlignment != 0
                ? item.Kirigami.FormData.labelAlignment
                : (item.Kirigami.FormData.buddyFor.height > height * 2 ? Qt.AlignTop : Qt.AlignVCenter)

            if (item.Kirigami.FormData.isSection) {
                return Qt.AlignLeft
            } else {
                if (root.wideMode) {
                    return Qt.AlignRight | verticalAlignment
                } else {
                    return Qt.AlignLeft | Qt.AlignBottom
                }
            }
        }

        /**
         * @param {Item} item
         *
         * @returns {number}
         */
        function effectiveTextLayout(item) {
            if (root.wideMode) {
                return item.Kirigami.FormData.labelAlignment != 0 ? item.Kirigami.FormData.labelAlignment : Text.AlignVCenter
            }
            return Text.AlignBottom
        }
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
    }

    Timer {
        id: relayoutTimer
        interval: 0
        onTriggered: {
            var __items = children;
            //exclude the layout and temp
            for (var i = 2; i < __items.length; ++i) {
                var item = __items[i];

                //skip items that are already there
                if (lay.knownItems.indexOf(item) != -1 ||
                    //exclude Repeaters
                    //NOTE: this is an heuristic but there are't better ways
279
                    (item.hasOwnProperty("model") && item.model !== undefined && item.children.length === 0)) {
280
281
282
283
284
285
286
287
288
289
290
291
292
                    continue;
                }
                lay.knownItems.push(item);

                var itemContainer = itemComponent.createObject(temp, {"item": item})

                //if section, label goes after the separator
                if (item.Kirigami.FormData.isSection) {
                    //put an extra spacer
                    var placeHolder = placeHolderComponent.createObject(lay, {"item": item});
                    itemContainer.parent = lay;
                }

Marco Martin's avatar
Marco Martin committed
293
                var buddy;
294
                if (item.Kirigami.FormData.checkable) {
Marco Martin's avatar
Marco Martin committed
295
                    buddy = checkableBuddyComponent.createObject(lay, {"item": item})
296
                } else {
297
                    buddy = buddyComponent.createObject(lay, {"item": item, "index": i - 2})
298
                }
Marco Martin's avatar
Marco Martin committed
299

300
                itemContainer.parent = lay;
Marco Martin's avatar
Marco Martin committed
301
                lay.buddies.push(buddy);
302
            }
Marco Martin's avatar
Marco Martin committed
303
304
            lay.knownItemsChanged();
            lay.buddiesChanged();
Marco Martin's avatar
Marco Martin committed
305
            hintCompression.triggered();
306
307
308
309
310
311
312
313
314
315
316
317
        }
    }

    onChildrenChanged: relayoutTimer.restart();

    Component {
        id: itemComponent
        Item {
            id: container
            property var item
            enabled: item.enabled
            visible: item.visible
Marco Martin's avatar
Marco Martin committed
318
319
320
321
322
323
324

            //NOTE: work around a  GridLayout quirk which doesn't lay out items with null size hints causing things to be laid out incorrectly in some cases
            implicitWidth: Math.max(item.implicitWidth, 1)
            implicitHeight: Math.max(item.implicitHeight, 1)
            Layout.preferredWidth: Math.max(1, item.Layout.preferredWidth > 0 ? item.Layout.preferredWidth : item.implicitWidth)
            Layout.preferredHeight: Math.max(1, item.Layout.preferredHeight > 0 ? item.Layout.preferredHeight : item.implicitHeight)

Marco Martin's avatar
Marco Martin committed
325
326
327
328
329
330
            Layout.minimumWidth: item.Layout.minimumWidth
            Layout.minimumHeight: item.Layout.minimumHeight

            Layout.maximumWidth: item.Layout.maximumWidth
            Layout.maximumHeight: item.Layout.maximumHeight

331
            Layout.alignment: Qt.AlignLeft | Qt.AlignVCenter
332
            Layout.fillWidth: item instanceof TextInput || item.Layout.fillWidth || item.Kirigami.FormData.isSection
333
334
335
336
337
338
            Layout.columnSpan: item.Kirigami.FormData.isSection ? lay.columns : 1
            onItemChanged: {
                if (!item) {
                    container.destroy();
                }
            }
Marco Martin's avatar
Marco Martin committed
339
340
            onXChanged: item.x = x + lay.x;
            //Assume lay.y is always 0
Marco Martin's avatar
Marco Martin committed
341
            onYChanged: item.y = y + lay.y;
342
            onWidthChanged: item.width = width;
Marco Martin's avatar
Marco Martin committed
343
            Component.onCompleted: item.x = x + lay.x;
Marco Martin's avatar
Marco Martin committed
344
345
            Connections {
                target: lay
346
                function onXChanged() { item.x = x + lay.x }
Marco Martin's avatar
Marco Martin committed
347
            }
348
349
350
351
352
353
354
355
356
357
        }
    }
    Component {
        id: placeHolderComponent
        Item {
            property var item
            enabled: item.enabled
            visible: item.visible
            width: Kirigami.Units.smallSpacing
            height: Kirigami.Units.smallSpacing
358
            Layout.topMargin: item.height > 0 ? Kirigami.Units.smallSpacing : 0
359
360
361
362
363
364
365
366
367
368
369
370
371
            onItemChanged: {
                if (!item) {
                    labelItem.destroy();
                }
            }
        }
    }
    Component {
        id: buddyComponent
        Kirigami.Heading {
            id: labelItem

            property var item
372
            property int index
373
            enabled: item.enabled && item.Kirigami.FormData.enabled
374
            visible: item.visible && (root.wideMode || text.length > 0)
375
376
377
            Kirigami.MnemonicData.enabled: item.Kirigami.FormData.buddyFor && item.Kirigami.FormData.buddyFor.activeFocusOnTab
            Kirigami.MnemonicData.controlType: Kirigami.MnemonicData.FormLabel
            Kirigami.MnemonicData.label: item.Kirigami.FormData.label
Devin Lin's avatar
Devin Lin committed
378
            text: Kirigami.MnemonicData.richTextLabel
379
            font.weight: root.wideMode || item.Kirigami.FormData.isSection ? Font.Normal : Font.Bold // use bold in narrow layouts for contrast
Devin Lin's avatar
Devin Lin committed
380
                                                                    
381
382
383
            level: item.Kirigami.FormData.isSection ? 3 : 5

            Layout.columnSpan: item.Kirigami.FormData.isSection ? lay.columns : 1
384
385
386
387
388
389
390
391
392
393
394
            Layout.preferredHeight: {
                if (item.Kirigami.FormData.label.length > 0) {
                    if (root.wideMode) {
                        return Math.max(implicitHeight, item.Kirigami.FormData.buddyFor.height)
                    } else {
                        return implicitHeight
                    }
                } else {
                    Kirigami.Units.smallSpacing
                }
            }
395

396
            Layout.alignment: temp.effectiveLayout(item)
397
            verticalAlignment: temp.effectiveTextLayout(item)
398

Carl Schwan's avatar
Carl Schwan committed
399
400
401
            Layout.fillWidth: !root.wideMode
            wrapMode: Text.Wrap

402
            Layout.topMargin: root.wideMode && item.Kirigami.FormData.buddyFor.parent != root ? item.Kirigami.FormData.buddyFor.y : ((index === 0 || root.wideMode) ? 0 : Kirigami.Units.smallSpacing * 2)
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
            onItemChanged: {
                if (!item) {
                    labelItem.destroy();
                }
            }
            Shortcut {
                sequence: labelItem.Kirigami.MnemonicData.sequence
                onActivated: item.Kirigami.FormData.buddyFor.forceActiveFocus()
            }
        }
    }
    Component {
        id: checkableBuddyComponent
        CheckBox {
            id: labelItem
            property var item
            visible: item.visible
            Kirigami.MnemonicData.enabled: item.Kirigami.FormData.buddyFor && item.Kirigami.FormData.buddyFor.activeFocusOnTab
            Kirigami.MnemonicData.controlType: Kirigami.MnemonicData.FormLabel
            Kirigami.MnemonicData.label: item.Kirigami.FormData.label

            Layout.columnSpan: item.Kirigami.FormData.isSection ? lay.columns : 1
            Layout.preferredHeight: item.Kirigami.FormData.label.length > 0 ? implicitHeight : Kirigami.Units.smallSpacing

427
            Layout.alignment: temp.effectiveLayout(this)
428
            Layout.topMargin: item.Kirigami.FormData.buddyFor.height > implicitHeight * 2 ? Kirigami.Units.smallSpacing/2 : 0
Marco Martin's avatar
Marco Martin committed
429

430
            activeFocusOnTab: indicator.visible && indicator.enabled
431
432
            //HACK: desktop style checkboxes have also the text in the background item
            //text: labelItem.Kirigami.MnemonicData.richTextLabel
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
            enabled: labelItem.item.Kirigami.FormData.enabled
            checked: labelItem.item.Kirigami.FormData.checked

            onItemChanged: {
                if (!item) {
                    labelItem.destroy();
                }
            }
            Shortcut {
                sequence: labelItem.Kirigami.MnemonicData.sequence
                onActivated: {
                    checked = !checked
                    item.Kirigami.FormData.buddyFor.forceActiveFocus()
                }
            }
            onCheckedChanged: {
                item.Kirigami.FormData.checked = checked
            }
            contentItem: Kirigami.Heading {
                id: labelItemHeading
                level: labelItem.item.Kirigami.FormData.isSection ? 3 : 5
                text: labelItem.text
455
                verticalAlignment: temp.effectiveTextLayout(labelItem.item)
456
                enabled: labelItem.item.Kirigami.FormData.enabled
457
                leftPadding: height//parent.indicator.width
458
459
460
461
462
463
464
            }
            Rectangle {
                enabled: labelItem.indicator.enabled
                anchors.left: labelItemHeading.left
                anchors.right: labelItemHeading.right
                anchors.top: labelItemHeading.bottom
                anchors.leftMargin: labelItemHeading.leftPadding
465
                height: 1
466
467
468
469
470
                color: Kirigami.Theme.highlightColor
                visible: labelItem.activeFocus && labelItem.indicator.visible
            }
        }
    }
Marco Martin's avatar
Marco Martin committed
471
}