Skip to content

Instantly share code, notes, and snippets.

@gridhead
Created April 24, 2026 12:42
Show Gist options
  • Select an option

  • Save gridhead/ccad188c40d36485a7ca7d4ee31a16f6 to your computer and use it in GitHub Desktop.

Select an option

Save gridhead/ccad188c40d36485a7ca7d4ee31a16f6 to your computer and use it in GitHub Desktop.
QMLG.md

How QML Works in This Project — A Guided Tour

1. The Bridge: Python meets QML

Everything starts in main.py:15-16:

provider = LoadoutProvider()
engine.rootContext().setContextProperty("loadout", provider)

This single line — setContextProperty("loadout", provider) — is the entire bridge between Python and QML. It takes your Python object and makes it available inside every QML file as the global name loadout. After this, any QML file can write loadout.character or loadout.setCharacter("Hu Tao") and it reaches directly into your Python class.

There's no import, no registration, no configuration file. One line, and your Python object is visible to the entire QML tree.


2. QML Component Anatomy

Open GlassCard.qml — it's the simplest custom component (29 lines):

Rectangle {
    id: card
    property color accentColor: "transparent"
    property bool hovered: false

    color: Qt.rgba(1, 1, 1, hovered ? 0.07 : 0.04)
    border.color: accentColor !== "transparent" ? ... : Qt.rgba(1, 1, 1, 0.08)
    radius: 12

    Behavior on color { ColorAnimation { duration: 200 } }
}

Every QML file defines one root element. Here it's Rectangle. Key concepts:

  • id: card — A local name so other items inside this file can say card.accentColor. It's file-scoped, never visible outside.

  • property color accentColor: "transparent" — This declares a public API. When someone uses GlassCard in another file, they can write GlassCard { accentColor: "#FF0000" }. Think of it like a function parameter with a default value.

  • color: Qt.rgba(1, 1, 1, hovered ? 0.07 : 0.04) — This is a binding, not an assignment. It's not "set color to this value once." It means "color is always this expression." Whenever hovered changes, color automatically recomputes. This is the most important concept in QML — bindings are live, reactive formulas.

  • Behavior on color { ColorAnimation { duration: 200 } } — Whenever the bound value of color changes, instead of snapping instantly, interpolate over 200ms. This is how every smooth transition in the app works.

The file name matters: GlassCard.qml means you can use GlassCard { ... } as a tag in other QML files, just like Rectangle or Text. Your filename is your component name.


3. Properties Flow Down, Signals Flow Up

Look at how StatsPanel.qml uses StatBar.qml:

StatsPanel declares what data it needs:

GlassCard {
    property var statsData: []
    property color elementColor: "#FFB13F"

Then it creates StatBars inside a Repeater:

Repeater {
    model: statsPanel.statsData

    StatBar {
        statName: modelData.name
        statDisplay: modelData.display
        statValue: modelData.value
        barColor: modelData.color
    }
}

And StatBar uses those properties internally:

Item {
    property string statName: ""
    property real statValue: 0

    Text { text: statBar.statName }

    Rectangle {
        width: parent.width * statBar.animatedPercent
    }
}

The pattern is: parent sets child properties, child uses them for display. Data flows downward through property assignment. When the parent's data changes, bindings automatically propagate the change down to every child.


4. Python to QML: Properties and Signals

In data_provider.py, the LoadoutProvider class exposes data to QML through the @Property decorator:

@Property("QVariantMap", notify=dataChanged)
def character(self):
    return {
        "name": c.name.value,
        "element": element,
        "element_color": _rgba_to_hex(c.vision.value.colour),
        "face_image": f"file://{IMAGES_BASE}/char/face/{img_name}.webp",
        ...
    }

Three pieces work together here:

  1. @Property("QVariantMap", ...) — Tells Qt "this Python method is a readable property, and it returns a dictionary." QML can now write loadout.character.name or loadout.character.element_color.

  2. notify=dataChanged — This is the key piece. It says "when I emit the dataChanged signal, QML should re-read this property." Without this, QML would read the value once and never update.

  3. dataChanged = Signal() — Declared at the class level. When Python calls self.dataChanged.emit(), every QML binding that references any property tied to this signal re-evaluates.

So the full cycle in main.qml:12:

property color elementColor: loadout.character.element_color || "#FFB13F"

This binding means: "my elementColor is always whatever loadout.character.element_color currently is." When Python emits dataChanged, QML re-reads character, gets the new dict, pulls .element_color from it, and updates elementColor. Because elementColor changed, every child that binds to root.elementColor also updates. The cascade is automatic.


5. QML to Python: Slots

When the user picks a character from the dropdown, the flow reverses.

In main.qml:153-154:

onActivated: function(index) {
    loadout.setCharacter(model[index]);
}

This calls into Python — data_provider.py:414:

@Slot(str)
def setCharacter(self, name):
    self._char_name = name
    self._char_obj = __charmaps__[name]()
    # ... update weapon type, recalculate stats ...
    self.dataChanged.emit()

The @Slot(str) decorator marks this method as callable from QML. The string parameter type tells Qt what argument to expect. At the end, self.dataChanged.emit() fires — which triggers QML to re-read every property bound to dataChanged, which cascades through every binding in the UI.

So the round trip is:

  1. User clicks dropdown → QML calls loadout.setCharacter("Hu Tao")
  2. Python updates internal state, recalculates everything
  3. Python emits dataChanged
  4. QML re-reads loadout.character, loadout.weapon, loadout.stats, etc.
  5. Every binding depending on those properties updates
  6. Animations (Behavior on ...) smooth the transitions

6. Bindings: The Reactive Core

The binding in main.qml:12 is simple — just reading a property. But bindings can be arbitrarily complex. Look at main.qml:145-153, where the character selector computes its currentIndex:

currentIndex: {
    var names = loadout.characterNames;
    var current = loadout.currentCharName;
    for (var i = 0; i < names.length; i++) {
        if (names[i] === current) return i;
    }
    return 0;
}

This is a multi-line JavaScript expression used as a binding. It runs automatically whenever loadout.characterNames or loadout.currentCharName changes. It searches the list for the current name and returns the matching index. No manual "update the index when the name changes" — the binding handles it.

Another example — the artifact slot visibility in ArtifactDetail.qml:219:

visible: index < loadout.activeSubstatCount

When you change rarity from 5-star (4 substats) to 4-star (3 substats), activeSubstatCount changes from 4 to 3. The 4th substat row's visible binding re-evaluates, returns false, and the row disappears. No imperative hide/show code needed.


7. Component Reuse: StyledComboBox

StyledComboBox.qml is used 12+ times across the app. Each instance configures it via properties:

StyledComboBox {
    label: "ELEMENT"
    model: loadout.elementNames
    searchable: false
    elementColor: root.elementColor
    onActivated: function(index) {
        loadout.setElementFilter(model[index]);
    }
}

vs.

StyledComboBox {
    label: "NAME"
    model: loadout.characterNames
    searchable: true
    elementColor: root.elementColor
    onActivated: function(index) {
        loadout.setCharacter(model[index]);
    }
}

Same component, different configuration. The searchable: true property controls whether the search TextInput appears (StyledComboBox.qml:155visible: combo.searchable). The model property determines what items populate the list. The onActivated signal handler determines what Python method gets called.


8. Color Theming: One Property, Entire UI

The entire app theme changes when you switch characters. Here's how:

Python returns the character's element color:

"element_color": _rgba_to_hex(c.vision.value.colour)  # e.g., "#FF6B6B" for Pyro

Root QML binds to it with an animation:

property color elementColor: loadout.character.element_color || "#FFB13F"
Behavior on elementColor { ColorAnimation { duration: 600 } }

Every child receives it as a property:

WeaponCard { elementColor: root.elementColor }
ArtifactSlot { elementColor: root.elementColor }
StyledComboBox { elementColor: root.elementColor }

Each component uses it internally:

border.color: Qt.rgba(Qt.color(combo.elementColor).r,
                      Qt.color(combo.elementColor).g,
                      Qt.color(combo.elementColor).b, 0.5)

Switch from Hu Tao (Pyro red) to Ganyu (Cryo blue), and every border, accent, glow, and particle in the entire UI smoothly transitions to blue — because root.elementColor changed, and everything is bound to it.


9. Signals vs. Properties vs. Slots — Summary

Mechanism Direction Purpose Example
@Property Python → QML Expose data for QML to read loadout.character.name
Signal.emit() Python → QML Tell QML "re-read the data, it changed" self.dataChanged.emit()
@Slot QML → Python Let QML call Python methods loadout.setCharacter(name)
QML property Parent → Child Pass data down the component tree elementColor: root.elementColor
QML signal Child → Parent Notify parent of events onActivated: function(index) { ... }
QML binding Automatic Keep values in sync reactively text: loadout.character.name

The entire architecture is: Python holds the state and logic. QML holds the layout and presentation. Properties carry data from Python into QML. Slots carry user actions from QML back into Python. Signals tell QML when to re-read. Bindings propagate changes automatically through the component tree.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment