Qt Quick and QML, as Sioul uses them¶
This page is for a developer who has not written QML. It shows how Sioul's window is written, with excerpts from its pages, and names the pitfalls the code's comments point at. Qt's own documentation teaches the language: the QML reference. How QML reaches Rust is the next guide, the bridge.
What Qt, Qt Quick and QML are¶
- Qt is a C++ framework for programs with windows, on Linux, Windows, macOS and Android. Sioul uses Qt 6 (6.9 or newer, building.md).
- QML is Qt's language for interfaces. A
.qmlfile describes a tree of objects, each with properties (its values), signals (what it says happened) and functions, written in JavaScript. - Qt Quick is the library of visual objects QML is drawn with (
Item,Rectangle,Text,Image,ListView…); Qt Quick Controls adds buttons, fields, menus and dialogs; Qt Quick Layouts places them in rows, columns and grids.
The window's QML is in crates/sioul-app/qml/. It holds no rule about your data: Rust gives it text and lists, already decided and worded, and QML lays them out (start-here.md).
A QML file is a type¶
Each file defines one type, named after the file. crates/sioul-app/qml/StoppedCard.qml, the card "Where you stopped", begins:
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls.Basic
import QtQuick.Layouts
Panel {
id: card
required property var sioul
required property var window
property var stopped: null
function reload() {
card.stopped = JSON.parse(card.sioul.stopped() || "null")
}
visible: card.stopped !== null
Component.onCompleted: card.reload()
- The imports: Qt Quick, its controls in the Basic style (below), its layouts. A file that names Sioul's own types written in Rust (
Sioul,Desktop) also imports Sioul's module,import com.aurelienpierre.sioul, asmain.qmldoes. Panel { … }: the card is aPanel, Sioul's own type fromPanel.qmlin the same folder: a soft surface with a thin border, itself a QtPane. Another file uses the card the same way, asStoppedCard { … }(PorchPage.qml,TasksPage.qml).id: cardnames the object inside this file. Ids are not properties: other files cannot see them.property var stopped: nulldeclares a property.varholds any JavaScript value; the code also usesstring,bool,int,realandcolor.function reload()is JavaScript. Here it asks the bridge object for "where you stopped" (card.sioul.stopped(), a JSON text) and keeps it parsed.Component.onCompletedruns once the object is made.
Each object in the file is written the same way: its type, then its properties, its handlers and its children between braces.
Properties and bindings¶
visible: card.stopped !== null is not assigned once: it is a binding. QML notes which properties the expression read (card.stopped) and computes it again whenever one of them changes. Most of a page is bindings: a label's text, a button's enabled, a row's visible, all follow the data they read. This is what makes a page show new data without code that updates it.
- A binding reads properties, never the disk. When a property of the bridge object changes (Rust sets
porch,tasks…), every binding that reads it runs again (cxx-qt.md). readonly propertycan only be bound, never assigned:readonly property var rows: JSON.parse(page.sioul.overlaps || "[]").filter(o => o.today)inPorchPage.qml.-
Assigning breaks a binding.
card.stopped = …inreloadis an assignment, which is fine for a property that had none. But assigning to a property that had a binding replaces the binding for good. Checkable buttons meet this: a click on a checked button unchecks it, and itschecked: …binding is gone.BudgetDetail.qmlbinds it again withQt.binding: -
A
varholding an object or an array tells nobody when its inside changes, only when the property itself is given a new value.main.qmlkeeps which pages are made inmade, an object; to mark one, it copies it, changes the copy, and assigns it, so thatonMadeChangedruns:
Qt's documentation: property binding.
Signals and handlers¶
A signal says that something happened; a handler is the code that runs then. Each property has its own: stopped changing emits stoppedChanged, handled by onStoppedChanged. Controls have theirs: a Button emits clicked, handled by onClicked. A file declares its own with signal changed (Interruption.qml) and emits it by calling it, dialog.changed().
To handle the signals of another object, such as the bridge object every page shares, a page uses Connections. StoppedCard.qml reads itself again when the tasks or the Porch change, since the line may have been left or cleared elsewhere (the focus window, another page, another device):
Connections {
target: card.sioul
function onTasksChanged() {
card.reload()
}
function onPorchChanged() {
card.reload()
}
}
The bridge's own signals arrive the same way: main.qml handles onCopyRequested(text), onTaskDone(uid) and others in a Connections whose target is sioul. Qt's documentation: signal and handler event system.
Required properties, and how a page gets sioul¶
required property var sioul says that whoever makes the card must give it sioul; QML refuses to make it otherwise. This is how every page and every part of a page reaches the one bridge object:
-
main.qmlmakes it once, with the theme: -
Each page receives it when it is made, among its required properties:
loaders[i].setSource(window.pageFiles[i], given)(makePagesinmain.qml), wheregivenholdssioul,themeand, for most pages,window. -
Each part of a page receives it from the page.
PorchPage.qmlmakes the card so:
Three objects travel this way everywhere: sioul (the bridge to Rust), theme (colours and sizes) and window (the main window, which opens things: card.window.openTask(card.stopped.task)). Qt's documentation: required properties.
pragma ComponentBehavior: Bound¶
Most of the window's files start with this line. A component is a piece of QML written inside a file to be made later, perhaps many times: a list's delegate, a Loader's sourceComponent. With the pragma, each component of the file stays bound to the file it is written in: it may use the file's ids, and Qt's tools can check those uses. Its delegates then take their data as required properties, never as names the model would put in their scope:
Repeater {
model: overlapsCard.rows
delegate: ColumnLayout {
id: overlap
required property var modelData
…
}
}
Qt's documentation: QML document structure, pragmas.
Lists from JSON¶
Rust hands most lists to QML as JSON text. QML parses them, and a Repeater or a ListView makes one delegate (one copy of a component) per entry, given the entry as modelData. The Porch's card of overlapping events (PorchPage.qml):
Panel {
id: overlapsCard
// Made with the agenda, off the window's thread.
readonly property var rows: JSON.parse(page.sioul.overlaps || "[]").filter(o => o.today)
visible: overlapsCard.rows.length > 0
…
Repeater {
model: overlapsCard.rows
delegate: ColumnLayout {
id: overlap
required property var modelData
…
Label {
text: page.sioul.textArgs("overlap-today", JSON.stringify({ first: …, second: … }))
}
Repeatermakes every delegate at once: for short lists (a card's lines, a menu's entries).ListViewmakes only the rows in sight, and withreuseItems: truereuses rows that scroll away (ContactsPage.qml): for long lists.- The
|| "[]"covers the first moment, before Rust has set the property.
Qt's documentation: Repeater, ListView.
Made when first needed¶
Making QML objects costs time. On a 2019 phone, the Accounts page took five seconds to open while it made every form it holds; with its parts made when first needed, each page now opens in 0.1 to 0.55 seconds the first time (android.md). So the window makes little at first, and the rest when it is first shown.
- Pages:
main.qmlholds one emptyLoaderper page.madelists the pages made; the Porch is the only one at the start. Showing a page for the first time marks it, andmakePagesgives itsLoaderthe page's file withsetSource(file, { sioul: …, theme: …, window: … }). A page made is then kept. -
Forms, dialogs and menus:
Later.qml, aLoadernot active untilnow()is called:Loader { // Out of the layouts it sits in: a form opens over the window. visible: false active: false function now() { active = true return item } function close() { if (item) item.close() } }A page writes
form.now().open(), neverform.open(). -
Windows that open with a session: a
Loaderwhoseactiveis bound to the state, such as the focus window's (active: sioul.focusSession !== ""inmain.qml). - What follows from it: an object made later does not exist before. A
Loader'sitemisnulluntil then, so code that reads it checks first (titleBar.item ? … : nullinmain.qml); and an id inside aLateror aLoaderis not seen outside it: the page reaches the object throughnow()oritem.
Qt's documentation: Loader.
The Basic style and the theme¶
- One style, Basic. Qt Quick Controls come in several styles (Material, Fusion, the system's…). Every file that uses controls imports
QtQuick.Controls.Basic, andcrates/sioul-app/src/lib.rssetsQT_QUICK_CONTROLS_STYLE=Basicunless you chose another, so that even Qt's own tooltips are drawn in it. Qt's documentation: styles. Theme.qmlis aQtObject(an object that draws nothing) with Sioul's colours, light or dark (background,surface,text,muted,accent…), its spacing (gap, narrower on a phone) and radius, and the reading settings (readingFamily,readingSize,readingSpacing).main.qmlmakes it once, and it travels astheme. Pages take their colours from it (color: card.theme.text); the one exception is the map's credit, drawn over the map's own colours (ContactMap.qml).SioulWindow.qml, the type of every window of Sioul, gives the theme's colours to Qt's controls through the window'spalette.Panel.qml, the card;Icon.qml, an icon;SioulMenu.qml, every menu (as wide as its widest line, kept inside the window). Use them rather than Qt's types directly, so that every page looks the same.
Icons, fonts and resources¶
A resource file (.qrc) lists files that the build compiles into the program; QML and C++ reach them by addresses that begin with qrc:/ or :/.
- The pages themselves are resources of Sioul's QML module:
crates/sioul-app/src/lib.rsloadsqrc:/qt/qml/com/aurelienpierre/sioul/qml/main.qml. - Icons are named as the desktop's icon themes name them (
view-task,mail-message:Theme.qml'skindIcons), and drawn byIcon.qml, anIconImageread on a thread. The Breeze icons Sioul uses are bundled, light and dark, incrates/sioul-app/icons/;crates/sioul-app/src/desktop.rs(icons) makes them stand in for whatever the desktop's icon theme lacks. A new icon: name it in the QML or the Rust, then runtools/bundle-icons.pyagain (it needs the Breeze icon theme installed), which copies every Breeze icon the sources name and writesicons.qrc. It never empties the folder: Sioul's own icons there (sioul-web.svg, itsOWNlist) are kept and listed, a Breeze icon no longer named goes, and nothing else is touched. - Sioul's own icon (
app.qrc) and Sioul Symbols, a small font of the symbols system fonts may lack (fonts/fonts.qrc, made bytools/make-symbols-font.py).
build.rs names the three .qrc files (cxx-qt.md). Qt's documentation: the Qt resource system.
Words¶
A page holds no sentence of its own (proper names and credits aside, such as © OpenStreetMap): every text is asked of Rust by its message's name, sioul.text("stopped-done"), sioul.textWith("stopped-at", "when", card.stopped.when), sioul.textArgs(…). The Qt way, qsTr(), is not used. How the messages are written: fluent.md.
Words from outside, as plain text¶
Qt guesses rich text. A Text or a Label left at Text.AutoText, the default, draws as HTML any string whose first tag looks like one; the controls (Button, MenuItem, CheckBox, RadioButton, ItemDelegate, TabButton, an Action, a ToolTip), the title of a Dialog or a Menu, a text field's placeholderText and a ComboBox's list draw their words through such an item, with no textFormat to say otherwise. A name, a subject or an event's title holding <img src="http://…"> then loads that picture from the network, which tells whoever chose the address that it was seen, when and from where; and a button holding it crashed the window (Qt 6.11). Words from outside are everywhere: a sender's name, a contact's or a list's, an event's title from a shared calendar, a folder's name from a server, a site's, a caller's. So every binding that draws words shows one of:
- fixed words: a string, a number, a translation (
sioul.text(…)), orsioul.textWith(…)/sioul.textArgs(…)whose values are fixed words themselves; - words made plain:
theme.plain(…)(Theme.qml) puts a word joiner, U+2060, after each "<", so that Qt never takes one for a tag, and nothing shows of it; where a file has no theme, the same replacement is written out (Icon.qml, the menus of usual sites). In rich text on purpose (a link), words are escaped instead:theme.escaped(…); - a
Textor aLabeltoldtextFormat: Text.PlainText, the simplest for them, whatever its binding.
Examples, as the pages write them:
Label {
text: row.modelData.name
textFormat: Text.PlainText
}
Button {
text: page.theme.plain(page.sioul.textWith("overlap-open", "title", openOne.modelData.title))
}
PlainComboBox {
textRole: "label"
valueRole: "id"
model: column.words.fields.map(f => Object.assign({}, f, { label: column.theme.plain(f.label) }))
}
What a person types (a TextField, a TextArea) is plain text already, and kept as typed. Code that saves or sends reads the data, never the words a control draws (a ComboBox's currentText): the word joiner would go with them (the form that makes a task list takes its account by its place in the list, TasksPage.qml). The few views that are rich on purpose (a message's HTML, made safe in Rust by reading::safe_html; Markdown made HTML by compose::markdown_html, which shows HTML written in it as words and a picture from elsewhere as a link) say why in a comment holding "rich on purpose:", on their line or the line above. tools/check-plain-text.py holds every page to this ("Checking QML", below), and tst_plaintext.qml shows Sioul's components such words while a stand-in web server notes what is asked of it: nothing is fetched, nothing crashes.
"&" in a list or a menu. A button and a menu line read "&" as the mark of their shortcut key and leave it out ("R&D" drew "RD"), and a ComboBox's list is made of such lines. Menus double each "&" of words from outside (.replace(/&/g, "&&"), "&&" being one). Every ComboBox of the window is a PlainComboBox (PlainComboBox.qml), whose list does it, its closed box being a text field, which shows the text as it is; a ComboBox with a list of its own doubles the "&" itself (the task lists' in TaskPanel.qml). tst_plaincombobox.qml measures what a line draws against the same words in a plain Text, Qt's own list included, which leaves the "&" out.
A computer and a phone¶
The same files serve both. What differs:
- The width:
main.qml'scompactis true below 720 pixels (a phone, or a narrow window). The places then come over the pages from the left (☰), and each page shows one pane at a time; a page that opened something (a message, a task) says so withcanGoBack, and itsback()closes it, which Android's Back calls.theme.compactnarrows the margins. - The system:
Qt.platform.os === "android"where only a phone differs (the window has no title bar of its own there, nor a tray). - An editing card's width (the task panel, the event form, "How was it?"): no cap of its own. Its natural width is its fields at about 70 characters of the body font (a readable line: Bringhurst's 45 to 75 characters), or its tiles side by side (
CostTiles.naturalWidth), whichever is wider, measured with aFontMetricsof the card's own font. The task panel takes it while the list beside it keeps 480 pixels, or half the room in a narrower window (TasksPage.panelWidth); the event form and "How was it?" take it up to the window less the usual margins (theme.gap). On a phone, a card takes the whole width less those margins. - Fingers:
theme.touch(Android, and the runner's phone size) keeps what is tapped a fingertip wide: the tiles' cells are 30 pixels at least, and the tiles stand in one column wherever two would make them narrower. - Files of their own: Android has no Qt WebEngine and no Qt PDF, so
qml/android/SitesPage.qmlandqml/android/PdfView.qmlreplace the computer's (build.rslists one or the other, andmain.qml'spageFilespicks the Sites page). The tray,qml-desktop/Tray.qml, is loaded on computers only, in a folder apart so that the phone's build does not carry it. -
Touch: a right click opens a menu with a mouse; a finger has no buttons. Each such place has two handlers, one for the mouse and the touchpad, one for the touch screen's long press (
DndApplet.qml):TapHandler { acceptedButtons: Qt.RightButton // A touch has no buttons: on a touch screen, the long press below. acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad onTapped: applet.openMenu() } TapHandler { id: hold acceptedDevices: PointerDevice.TouchScreen onLongPressed: { … applet.openMenu() } } -
Checking a phone's layout on a computer:
SIOUL_GRAB_PHONElays the window out at a phone's size (412 × 891) when it saves images of the pages (android.md).
Pitfalls the code's comments name¶
- Binding loops. A binding that, through others, changes what it reads, runs again and again; Qt stops it and warns ("Binding loop detected").
TaskPanel.qmlgives the title field a fixedimplicitWidth: 120, since "the text's width would follow the width it sets, a binding loop".MailPage.qmlopens the first folder inQt.callLater(…), after the bindings reading the accounts are done, because opening it at once "changes what they read (a binding loop when done at once)".Qt.callLaterruns a function once the current changes are over. - Assigning over a binding ends it (above): bind it again with
Qt.binding. - What is made later is
nullbefore (above): check aLoader'sitem, reach a form throughnow(). - Changing an object inside a
vartells nobody (above): assign a new one. - A page out of sight keeps what it showed:
PorchPage.qmlcopiessioul.porchinto its ownporchTextonly while it is visible, and again when it comes back, so that results landing meanwhile cost nothing. - A window shown too early:
main.qmlsetsvisible: trueand maximizes the window inComponent.onCompleted; its comment says that avisibilityset in the declaration "left a phone's window unshown: a black screen". - A tray menu shown by itself:
Qt.labs.platform'sMenuis visible by default, and a visible menu is a menu on the screen: Plasma's tray menu, and the widgets' fallback, then opened it at the screen's top left corner, unasked, as Sioul started.qml-desktop/Tray.qmlsetsvisible: falseon it; a right click on the tray icon still opens it. - Every object made costs something later: with Qt 6.11, each item shown, hidden or restacked makes Qt walk the window's whole tree at the next frame (android.md). Make as little as the page needs.
Checking QML¶
- qmllint, Qt's linter:
tools/lint-qml.sh, aftercargo build --release -p sioul-app. It lints the pages as one module, with theqmldirand the type description (plugin.qmltypes) the build wrote, so that qmllint knows Sioul's own types. Qt's documentation: qmllint. - Words from outside drawn as plain text:
tools/check-plain-text.py, whichtools/lint-qml.shandtools/final-pass.shrun, needs no build. It reads each binding that draws words (text:,title:,ToolTip.text:,placeholderText:, aComboBox'smodel:by itstextRole,ToolTip.show(…), and words given in JavaScript,label.text = …, to an item named by its id) with a small parser of the JavaScript the pages use: a ternary is safe when both its branches are,a + bwhen both are,x.map(v => …)when what it maps to is, a comparison always; any other name or call is someone's words until made plain. A custom component is followed to its root type: aButtonwith acontentItemof its own draws its words itself, and is checked inside. It prints each binding that fails,file:line: Type.property: expression, and exits with 1 when there is one ("Words from outside, as plain text", above). - Tests:
tools/qml-test.shruns the QML tests ofcrates/sioul-app/tests/qml/, each component on a stand-in for Sioul, with Qt's QtTest; no build needed (building.md). - Images of every page:
tools/demo/run.sh <folder> en pagesstarts the window on the demo profile, in a sandbox, shows each page in turn, saves it and quits (SIOUL_GRAB); another step list in place ofpagesacts in the window. It is the one way to start the window for a check (building.md). - Qt's messages (QML errors, warnings,
console.info): Fedora's Qt sends them to the system journal;QT_FORCE_STDERR_LOGGING=1brings them back to the terminal. Each page's making time is logged assioul-perf: … made in … ms.