preview

Warning

This tutorial is a bit outdated. Certain API commands have changed, so the code blocks below may not work out of the box. However the general ideas still hold, so this might be useful for learning QML itself.

Step by step: Simple theme

In this tutorial, I'll show the theme creation in practice by implementing the theme above from scratch. Before we start, you might want to be familiar with the QML basics and dynamic layout elements (see the QML tutorials).

The theme has the list of games on the left, in a scrollable menu, with the collection's logo above it. Then some basic information is shown on the right, like title, developer, release year, description, and also a box art.

Let's get started!

Initial files

I'm on Linux, so my theme directory is at ~/.config/pegasus-frontend/themes. I'm creating a new directory called tutorial-simple, and in it my theme.cfg, like this:

name: Simple theme
author: Mátyás Mustoha

and the basic theme.qml:

import QtQuick 2.0

FocusScope {

}

Now I open Pegasus and select this theme on the Settings screen. I'll keep Pegasus open during the development, and refresh the theme with the F5 key after changes. I also open the main log file ~/.config/pegasus-frontend/lastrun.log which will tell me if I've made any errors in the QML file.

Tip

You can use whatever text editor you like. Qt Creator is cross platform, has good auto-complete and syntax highlight features. For a more lightweight editor, Sublime Text with the QML package, Atom or Notepad++ could be used, among others.

Note

You might see .qmlc files popping up in your theme's directory. These are cache files, generated for faster loading. When copying the theme to a different machine, you don't have to bring them, they'll automatically get generated the next time you launch Pegasus.

Base layout

The theme consists of a left and right main panel, so I'll start by adding them first. The left menu panel will take 1/3 of the whole screen, and have a lighter gray color. The right panel will be darker and take the rest of the screen.

import QtQuick 2.0

FocusScope {

    Rectangle {
        id: menu

        color: "#555"

        width: parent.width * 0.3
        anchors.top: parent.top
        anchors.bottom: parent.bottom
    }

    Rectangle {
        id: content

        color: "#222"

        anchors.left: menu.right
        anchors.right: parent.right
        anchors.top: parent.top
        anchors.bottom: parent.bottom
    }

}

Help

Using anchors you can define the position and size of elements relative to each other; see anchoring.

If you now refresh Pegasus, you'll now see that the screen has been divided between the two shades of gray. You can also see the width of the left panel changing automatically when you resize the application's window.

Left panel

Lt's continue with the menu panel, as this will be the more complex part of the theme.

I'd like to add a 50px padding around the contents of the left panel. But the players have all kinds of screens and devices, with larger or smaller resolutions -- how can I define it in a resolution-independent way?

You can define values using percentages, like we did with the width earlier, but for cases where more precision is desired, the vpx function might be of use. These virtual pixel values will automatically scale up or down depending on the screen resolution: a vpx(60) value will turn into 60px on a 720p screen, 90 real pixels on 1080p and 40 on 480p, while keeping the aspect ratio. Combining them with anchors and regular percentages, you can define and position elements in a quite detailed way.

So, I want to add a 50px padding for the items -- and by this, I mean a padding that will be 50px on a screen with 1280x720 resolution, and scale up or down for other sizes. For later use, I'll store the amount of the remaining "content width" in a property:

Rectangle {
    id: menu

    property real contentWidth: width - vpx(100)

    color: "#555"

    width: parent.width * 0.3
    anchors.top: parent.top
    anchors.bottom: parent.bottom
}

Lets's add the collection's logo to the panel. First of all you'll need a bunch of game system logo images. I've borrowed them from EmulationStation (original: Nils Bonenberger, CC-BY-NC-SA), except the RetroPie logo (original: Florian Müller, CC-BY-NC-SA). You can get them here. The file names match the system names from EmulationStation. Simply create a new directory inside your theme's folder, eg. assets, and extract them there.

After that, I add an Image element inside the menu Rectangle:

Rectangle {
    id: menu

    property real contentWidth: width - vpx(100)

    color: "#555"

    width: parent.width * 0.3
    anchors.top: parent.top
    anchors.bottom: parent.bottom

    Image {
        id: collectionLogo

        width: parent.contentWidth
        height: vpx(60)

        fillMode: Image.PreserveAspectFit
        source: "assets/" + api.currentCollection.shortname + ".svg"
        asynchronous: true

        anchors.horizontalCenter: parent.horizontalCenter
        anchors.top: parent.top
        anchors.topMargin: vpx(50)
    }
}

Some interesting things here:

Changing collections

Pressing left or right feels kind of natural to use for selecting the next/previous collection. The CollectionList item of the API provides the functions called incrementIndex() and decrementIndex(), which we call for this.

I'll handle of the Left and Right keys the top of the whole FocusScope, and simply call these two functions:

import QtQuick 2.0

FocusScope {

    Keys.onLeftPressed: api.collectionList.decrementIndex()
    Keys.onRightPressed: api.collectionList.incrementIndex()


    Rectangle {
        // ...
    }

    Rectangle {
        // ...
    }
}

After a refresh, pressing Left and Right should now change the logo on the screen. Yay!

Note

Assuming you have more than one collection, of course.

Tip

Handling keyboard keys also enables gamepad navigation. See the controls page about how keys and buttons are related.

Game list

This is going to be actually the most complex piece of the theme. The games for the currently selected collection can be accessed via api.currentCollection.gameList, with gameList.model being the list of games, gameList.current the currently selected game (also available as api.currentGame) and gameList.index the index of the selected element. The index can be set manually to a number, or changed by the increment/decrement functions, similarly to the collections.

Returning to the menu Rectangle, I add a ListView after the logo, first setting just the dimensions:

Rectangle {
    id: menu

    // ...

    Image { /* ... */ }


    ListView {
        id: gameView

        width: parent.contentWidth
        anchors.horizontalCenter: parent.horizontalCenter
        anchors.top: collectionLogo.bottom
        anchors.bottom: parent.bottom
        anchors.margins: vpx(50)
    }
}

Now to actually see something, ListView needs a Model: a list of items, and a Delegate: the definition of how one item should look like on the screen.

Our model will be the games of the currently selected collection, and for every game, the visual representation will be a text item:

ListView {
    id: gameView

    model: api.currentCollection.gameList.model
    delegate: Text {
        text: modelData.title
    }

    // anchors, etc.
}

You should now see the list of games, changing with the selected collection on pressing Left/Right.

Note

gameList.model is a list of Game objects. modelData in the delegate will be one Game object of the Model.

I'll do two quick reorganization in the code:

Rectangle {
    id: menu

    // ...

    Image { /* ... */ }


    ListView {
        id: gameView

        property var gameList: api.currentCollection.gameList

        model: gameList.model
        delegate: gameViewDelegate

        width: parent.contentWidth
        anchors.horizontalCenter: parent.horizontalCenter
        anchors.top: collectionLogo.bottom
        anchors.bottom: parent.bottom
        anchors.margins: vpx(50)
    }

    Component {
        id: gameViewDelegate

        Text {
            text: modelData.title
        }
    }
}

Tip

Component is a special element that defines a QML document. Just as QML files can have only one root element, Components can have only one child. You could actually move the Text to a separate file (eg. GameListItem.qml) and use the file's name to set the delegate (eg. delegate: GameListItem { }).

Let's continue with tweaking the menu items:

Component {
    id: gameViewDelegate

    Text {
        text: modelData.title

        // white, 20px, condensed font
        color: "white"
        font.family: globalFonts.condensed
        font.pixelSize: vpx(20)

        // the size of the whole text box,
        // a bit taller than the text size for a nice padding
        width: ListView.view.width
        height: vpx(36)
        // align the text vertically in the middle of the text area
        verticalAlignment: Text.AlignVCenter
        // if the text is too long, end it with ellipsis (three dots)
        elide: Text.ElideRight
    }
}

This is how it should look like:

screenshot

To see which one of the menu items is the selected one, I'll change its color and make it bigger and bold:

Component {
    id: gameViewDelegate

    Text {
        text: modelData.title

        // orange if this item is the currently selected one, white otherwise
        color: ListView.isCurrentItem ? "orange" : "white"
        font.family: globalFonts.condensed
        // bigger if selected
        font.pixelSize: ListView.isCurrentItem ? vpx(24) : vpx(20)
        // bold if selected (hint: isCurrentItem is already a true/false value)
        font.bold: ListView.isCurrentItem

        width: ListView.view.width
        height: vpx(36)
        verticalAlignment: Text.AlignVCenter
        elide: Text.ElideRight
    }
}

Help

When setting values, the X ? A : B form is called "inline-if": if the condition X is true, A will be used, otherwise B. In our case, it would equal to the following JavaScript code:

if (ListView.isCurrentItem)
    return "orange"
else
    return "white"

(which you can also use, if you wish).

Game selection

You should now see one item being highlighted on the list. Now, to make the game selection work, we have to do two thing:

To update the list when the index in the API changes, bind gameList.index to the ListView's currentIndex:

ListView {
    id: gameView

    property var gameList: api.currentCollection.gameList

    model: gameList.model
    delegate: gameViewDelegate
    currentItem: gameList.index

    width: parent.contentWidth
    anchors.horizontalCenter: parent.horizontalCenter
    anchors.top: collectionLogo.bottom
    anchors.bottom: parent.bottom
    anchors.margins: vpx(50)
}

While for the user input, call the index increment/decrement function of the current game list, like we did with the collection changing. At the top of our file:

import QtQuick 2.0

FocusScope {

    Keys.onLeftPressed: api.collectionList.decrementIndex()
    Keys.onRightPressed: api.collectionList.incrementIndex()
    Keys.onUpPressed: api.currentCollection.gameList.decrementIndexNoWrap()
    Keys.onDownPressed: api.currentCollection.gameList.incrementIndexNoWrap()


    Rectangle {
        // ...
    }

    Rectangle {
        // ...
    }
}

Tip

There's a wrapping and a non-wrapping variant of the index changing functions. Use whichever is maching for your themes.

Note

Of course, you can put the key handling somewhere else, I've merely found it convenient there. Just make sure the item in which you put it has the active focus (eg. has focus: true).

The list should now scroll around with a visible highlight for the current selection.

Launching games

The last feature required to make our theme functional is launching games. Game objects from the API have a launch() command you can call -- in the most common case, all you need to do is calling api.currentGame.launch().

Simply call it on Enter:

import QtQuick 2.0

FocusScope {

    Keys.onLeftPressed: api.collectionList.decrementIndex()
    Keys.onRightPressed: api.collectionList.incrementIndex()
    Keys.onUpPressed: api.currentCollection.gameList.decrementIndexNoWrap()
    Keys.onDownPressed: api.currentCollection.gameList.incrementIndexNoWrap()
    Keys.onReturnPressed: api.currentGame.launch()


    Rectangle {
        // ...
    }

    Rectangle {
        // ...
    }
}

Help

Technically the Enter next to the letter keys is called "Return", and the one on the numeric pad is "Enter". Careful not to mix them up.

And with this, we have a functional theme!

Tweaks

Some additional (and optional) graphical tweaks to try out for extra fancyness.

Clipping

While scrolling, you might have noticed that items at the top and the bottom of the list seem to go out of the bounds of the ListView's area. Delegates are fully drawn on the screen if they are at least partially visible; to make sure nothing shows up outside the ListView's area, you can use the clip property:

ListView {
    id: gameView

    property var gameList: api.currentCollection.gameList

    model: gameList.model
    delegate: gameViewDelegate
    currentItem: gameList.index

    width: parent.contentWidth
    anchors.horizontalCenter: parent.horizontalCenter
    anchors.top: collectionLogo.bottom
    anchors.bottom: parent.bottom
    anchors.margins: vpx(50)

    clip: true
}

Now the ListView will cut the drawing at its borders.

Centering the current item

It'd be nice if the currently selected item would try to stay in the middle of the list:

We can set this by defining the "highlight range"; the ListView will then try to scroll the list so the currently selected item falls into this range.

The highlight range is defined by the start and end position. For our vertical ListView, these are the distances from the top edge of the item, in pixels. We can get the vertical center of the ListView simply as height / 2, and the height of one Text item was set to 36px previously. To position it exactly in the middle, I'll set the start of the range 18px above the center, and end it 18px below.

ListView {
    id: gameView

    property var gameList: api.currentCollection.gameList

    model: gameList.model
    delegate: gameViewDelegate
    currentItem: gameList.index

    highlightRangeMode: ListView.ApplyRange
    preferredHighlightBegin: height * 0.5 - vpx(18)
    preferredHighlightEnd: height * 0.5 + vpx(18)

    width: parent.contentWidth
    anchors.horizontalCenter: parent.horizontalCenter
    anchors.top: collectionLogo.bottom
    anchors.bottom: parent.bottom
    anchors.margins: vpx(50)

    clip: true
}

which should give the desired result.

However, when you change the collection, there is a few seconds of scrolling to the index of the newly selected game list. To disable this, and jump to the selected item instantly, set animation duration to zero:

ListView {
    id: gameView

    property var gameList: api.currentCollection.gameList

    model: gameList.model
    delegate: gameViewDelegate
    currentItem: gameList.index

    highlightRangeMode: ListView.ApplyRange
    highlightMoveDuration: 0
    preferredHighlightBegin: height * 0.5 - vpx(18)
    preferredHighlightEnd: height * 0.5 + vpx(18)

    width: parent.contentWidth
    anchors.horizontalCenter: parent.horizontalCenter
    anchors.top: collectionLogo.bottom
    anchors.bottom: parent.bottom
    anchors.margins: vpx(50)

    clip: true
}

Game information

With the game selection menu done, let's continue with the right side of the theme, the game informations. Metadata and assets of the currently selected game will be shown here; unlike the dynamic menu, these will be simple Text and Image objects, with their content coming from the API. We'll show the following information, in order:

layout

Box art

Add an Image object to the right side panel, with its source set to the current game's box art asset. The image will take the upper half of the screen, with a 50px padding around. As the box arts of different games may have different shapes (portrait, landscape, extra wide, etc.), it might be useful to define the maximum area an image may take up, and fit them in this area.

Rectangle {
    id: content

    color: "#222"

    anchors.left: menu.right
    anchors.right: parent.right
    anchors.top: parent.top
    anchors.bottom: parent.bottom


    Image {
        id: cover

        anchors.top: parent.top
        anchors.right: parent.right
        anchors.left: parent.left
        anchors.margins: vpx(50)
        anchors.bottom: parent.verticalCenter
        anchors.bottomMargin: 0

        fillMode: Image.PreserveAspectFit
        horizontalAlignment: Image.AlignRight

        source: api.currentGame.assets.boxFront
        sourceSize { width: 1024; height: 1024 }
        asynchronous: true
    }
}

Box art images might be quite big in resolution, so this time I've also set sourceSize: it limits the maximum amount of memory the image will take up. If the image is largen than this, it will be scaled down, keeping the aspect ratio. In this particular case, I've set it to scale down to 1024 × 1024 pixels (taking up about/at most 1024 × 1024 × 3 bytes = 3 MiB space in the memory).

Column and Row

The QML Column and Row object are great tools for aligning a fixed number of elements. In this case, the box art and the further Text items could be put into a Column that would anchors.fill its parent with a 50px anchors.margin, so I wouldn't have to define it for the Image itself and the other Texts.

So why I didn't use it? Column and Row works the best when the spacing between the elements is the same, but in this theme, I'd like to set some custom spacing between the elements later.

An alternative would be to put the elements into an Item. Perhaps you might want to try it as a practice?

Title

The rest of the game informations will be simple Text objects. The only thing that needs more attention is that the texts may be too long to fit on the screen, so we'll define a text area (similarly to the box art), and hide what doesn't fit into them.

So, for the title:

Rectangle {
    id: content

    // ...

    Image { /* ... */ }


    Text {
        id: title

        text: api.currentGame.title
        // white, big bold sans-serif font
        color: "white"
        font.family: globalFonts.sans
        font.pixelSize: vpx(42)
        font.bold: true

        horizontalAlignment: Text.AlignRight
        // if it's too long, end it with an ellipsis
        elide: Text.ElideRight

        // 40px below the box art
        anchors.top: cover.bottom
        anchors.topMargin: vpx(40)
        // left and right edges same as the image
        anchors.left: cover.left
        anchors.right: cover.right
    }
}

Developer and release

I'll put two Text items in a Row, and move the Row under the title. I didn't bother wth a maximum text width here, as company names tend to be not too long, while the release year should be just four numbers.

The code:

Rectangle {
    id: content

    // ...

    Row {
        id: shortInfo

        anchors.top: title.bottom
        anchors.right: title.right

        spacing: vpx(10)


        Text {
            text: api.currentGame.developer
            color: "white"
            font.pixelSize: vpx(18)
            font.family: globalFonts.sans
        }

        Text {
            text: api.currentGame.year
            color: "white"
            font.pixelSize: vpx(18)
            font.family: globalFonts.sans
            visible: api.currentGame.year > 0 // !!
        }
    }

}

Note that I only show the year when it's greater than 0. If we have no information about when the game was released, <Game>.year will be 0, which looks silly on the screen.

Description

A multiline text area. If the game has a short summar, it'll show that, otherwise the detailed description (or stay empty if none is available).

Rectangle {
    id: content

    // ...

    Text {
        id: description

        text: api.currentGame.summary || api.currentGame.description
        color: "white"
        font.pixelSize: vpx(18)
        font.family: globalFonts.sans

        wrapMode: Text.WordWrap
        horizontalAlignment: Text.AlignRight
        elide: Text.ElideRight

        anchors.top: shortInfo.bottom
        anchors.topMargin: vpx(40)
        anchors.bottom: parent.bottom
        anchors.bottomMargin: vpx(50)
        anchors.left: cover.left
        anchors.right: cover.right
    }
}

Conclusion

With all these changes, our theme is now complete and should look like this:

screenshot

Sure, perhaps not the most attractive yet, but I hope it helped learning theme creation. Feel free to tweak it to you liking, or make a new one based on it. Have fun!

Full code

Here is the full code, as one file. In real projects though, you might prefer to split your theme into multiple files.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 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
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
import QtQuick 2.0

FocusScope {

    Keys.onLeftPressed: api.collectionList.decrementIndex()
    Keys.onRightPressed: api.collectionList.incrementIndex()
    Keys.onUpPressed: api.currentCollection.gameList.decrementIndexNoWrap()
    Keys.onDownPressed: api.currentCollection.gameList.incrementIndexNoWrap()
    Keys.onReturnPressed: api.currentGame.launch()

    Rectangle {
        id: menu

        property real contentWidth: width - vpx(100)

        color: "#555"

        width: parent.width * 0.3
        anchors.top: parent.top
        anchors.bottom: parent.bottom

        Image {
            id: collectionLogo

            width: parent.contentWidth
            height: vpx(60)

            fillMode: Image.PreserveAspectFit
            source: "assets/" + api.currentCollection.shortname + ".svg"
            asynchronous: true

            anchors.horizontalCenter: parent.horizontalCenter
            anchors.top: parent.top
            anchors.topMargin: vpx(50)
        }

        ListView {
            id: gameView

            property var gameList: api.currentCollection.gameList

            model: gameList.model
            delegate: gameViewDelegate
            currentIndex: gameList.index

            highlightRangeMode: ListView.ApplyRange
            highlightMoveDuration: 0
            preferredHighlightBegin: height * 0.5 - vpx(18)
            preferredHighlightEnd: height * 0.5 + vpx(18)

            width: parent.contentWidth
            anchors.horizontalCenter: parent.horizontalCenter
            anchors.top: collectionLogo.bottom
            anchors.bottom: parent.bottom
            anchors.margins: vpx(50)

            clip: true
        }

        Component {
            id: gameViewDelegate

            Text {
                text: modelData.title

                color: ListView.isCurrentItem ? "orange" : "white"
                font.family: globalFonts.condensed
                font.pixelSize: ListView.isCurrentItem ? vpx(24) : vpx(20)
                font.bold: ListView.isCurrentItem

                width: ListView.view.width
                height: vpx(36)
                verticalAlignment: Text.AlignVCenter
                elide: Text.ElideRight
            }
        }
    }

    Rectangle {
        id: content

        color: "#222"

        anchors.left: menu.right
        anchors.right: parent.right
        anchors.top: parent.top
        anchors.bottom: parent.bottom

        Image {
            id: cover

            anchors.top: parent.top
            anchors.right: parent.right
            anchors.left: parent.left
            anchors.margins: vpx(50)
            anchors.bottom: parent.verticalCenter
            anchors.bottomMargin: 0

            fillMode: Image.PreserveAspectFit
            horizontalAlignment: Image.AlignRight

            source: api.currentGame.assets.boxFront
            sourceSize { width: 1024; height: 1024 }
            asynchronous: true
        }

        Text {
            id: title

            text: api.currentGame.title
            color: "white"
            font.family: globalFonts.sans
            font.pixelSize: vpx(42)
            font.bold: true

            horizontalAlignment: Text.AlignRight
            elide: Text.ElideRight

            anchors.top: cover.bottom
            anchors.topMargin: vpx(40)
            anchors.left: cover.left
            anchors.right: cover.right
        }

        Row {
            id: shortInfo

            anchors.top: title.bottom
            anchors.right: title.right

            spacing: vpx(10)

            Text {
                text: api.currentGame.developer
                color: "white"
                font.pixelSize: vpx(18)
                font.family: globalFonts.sans
            }

            Text {
                text: api.currentGame.year
                color: "white"
                font.pixelSize: vpx(18)
                font.family: globalFonts.sans
                visible: api.currentGame.year > 0
            }
        }

        Text {
            id: description

            text: api.currentGame.summary || api.currentGame.description
            color: "white"
            font.pixelSize: vpx(18)
            font.family: globalFonts.sans

            wrapMode: Text.WordWrap
            horizontalAlignment: Text.AlignRight
            elide: Text.ElideRight

            anchors.top: shortInfo.bottom
            anchors.topMargin: vpx(40)
            anchors.bottom: parent.bottom
            anchors.bottomMargin: vpx(50)
            anchors.left: cover.left
            anchors.right: cover.right
        }
    }
}