Full source code
This page shows the complete TrueAxis source for transparency. The Competitive edition is based on this code, while the Advanced edition includes additional features and optimizations. Use the Copy button to copy it all at once.
#!/usr/bin/env python3
import sys
import time
import threading
import pygame
import json
import os
import vgamepad as vg
from PySide6.QtCore import QObject, Signal, Slot, Property, QTimer, Qt, QEvent, QThread, QSettings
from PySide6.QtGui import QGuiApplication, QIcon, QPixmap, QPainter, QColor, QFont, QAction
from PySide6.QtQml import QQmlApplicationEngine
from PySide6.QtWidgets import QApplication, QSystemTrayIcon, QMenu, QStyle
# Try to import pynput for keyboard support
try:
from pynput.keyboard import Controller as KeyboardController, Key
KEYBOARD_AVAILABLE = True
except ImportError:
print("Warning: pynput not installed. Keyboard bindings will not be available.")
print("Install with: pip install pynput")
KEYBOARD_AVAILABLE = False
KeyboardController = None
Key = None
# --- Platform-specific imports for auto-start ---
if sys.platform == 'win32':
import winreg
import winshell
from win32com.client import Dispatch
elif sys.platform == 'darwin': # macOS
import subprocess
import plistlib
from pathlib import Path
# --- CONFIG & PROFILES ---
CONFIG_FILE = "trueaxis_config.json"
BUTTON_MAPPING_FILE = "trueaxis_buttons.json"
SETTINGS_FILE = "trueaxis_settings.json"
PROFILES = {
"Logitech G29 (Standard)": {"axes": [0, 2, 3], "desc": "Recommended first choice for G29"},
"Logitech G29 (Alt Mode)": {"axes": [0, 1, 2], "desc": "Use if Gas/Brake inputs are swapped"},
"Logitech G920 / G923": {"axes": [0, 1, 2], "desc": "Standard Xbox-layout wheels"},
"Driving Force GT": {"axes": [0, 1, 2], "desc": "Legacy Logitech wheels"},
"Logitech G27": {"axes": [0, 2, 3], "desc": "Classic G27 axis mapping"},
"PlayStation 4/5 (Standard)": {"axes": [0, 5, 4], "desc": "Steer: L-Stick, Gas: R2, Brake: L2"},
"PlayStation 4/5 (Alt Mode)": {"axes": [0, 4, 3], "desc": "Use if triggers are reversed"},
"PlayStation (DirectInput)": {"axes": [0, 2, 5], "desc": "Raw DirectInput mode"},
"Fanatec (Standard)": {"axes": [0, 1, 2], "desc": "Yellow compatibility mode"},
"Fanatec (Alt 1)": {"axes": [0, 2, 3], "desc": "Alternative axis configuration"},
"Fanatec (Alt 2)": {"axes": [0, 4, 5], "desc": "Common on CSL DD bases"},
"Fanatec (Alt 3)": {"axes": [0, 5, 6], "desc": "Higher axis pedal mapping"},
"Thrustmaster T300/T150": {"axes": [0, 1, 2], "desc": "Standard Thrustmaster mapping"},
"Thrustmaster T-GT / T248": {"axes": [0, 2, 1], "desc": "Newer generation wheels"},
"Thrustmaster (Combined)": {"axes": [0, 1, 1], "desc": "Combined pedal axis mode"},
"Generic Gamepad (Xbox/XInput)": {"axes": [0, 5, 4], "desc": "Standard Xbox controller layout"},
"Generic Wheel (DirectInput)": {"axes": [0, 1, 2], "desc": "Universal DirectInput wheels"},
"Combined Pedals Mode": {"axes": [0, 1, 1], "desc": "Single axis for Gas/Brake"},
}
XBOX_BUTTON_NAMES = [
"DISABLED",
"--- XBOX BUTTONS ---",
"A (Cross)",
"B (Circle)",
"X (Square)",
"Y (Triangle)",
"LB (L1)",
"RB (R1)",
"Back (Share)",
"Start (Options)",
"LThumb (L3)",
"RThumb (R3)",
"Guide (PS)",
"DPad Up",
"DPad Down",
"DPad Left",
"DPad Right",
"--- KEYBOARD KEYS ---",
"Key: Space",
"Key: Enter",
"Key: Shift",
"Key: Ctrl",
"Key: Alt",
"Key: Tab",
"Key: Esc",
"Key: 0",
"Key: 1",
"Key: 2",
"Key: 3",
"Key: 4",
"Key: 5",
"Key: 6",
"Key: 7",
"Key: 8",
"Key: 9",
"Key: A",
"Key: B",
"Key: C",
"Key: D",
"Key: E",
"Key: F",
"Key: G",
"Key: H",
"Key: I",
"Key: J",
"Key: K",
"Key: L",
"Key: M",
"Key: N",
"Key: O",
"Key: P",
"Key: Q",
"Key: R",
"Key: S",
"Key: T",
"Key: U",
"Key: V",
"Key: W",
"Key: X",
"Key: Y",
"Key: Z",
"Key: F1",
"Key: F2",
"Key: F3",
"Key: F4",
"Key: F5",
"Key: F6",
"Key: F7",
"Key: F8",
"Key: F9",
"Key: F10",
"Key: F11",
"Key: F12",
"Key: Up Arrow",
"Key: Down Arrow",
"Key: Left Arrow",
"Key: Right Arrow",
]
XBOX_BUTTON_MAP = {
"DISABLED": None,
"--- XBOX BUTTONS ---": None,
"A (Cross)": vg.XUSB_BUTTON.XUSB_GAMEPAD_A,
"B (Circle)": vg.XUSB_BUTTON.XUSB_GAMEPAD_B,
"X (Square)": vg.XUSB_BUTTON.XUSB_GAMEPAD_X,
"Y (Triangle)": vg.XUSB_BUTTON.XUSB_GAMEPAD_Y,
"LB (L1)": vg.XUSB_BUTTON.XUSB_GAMEPAD_LEFT_SHOULDER,
"RB (R1)": vg.XUSB_BUTTON.XUSB_GAMEPAD_RIGHT_SHOULDER,
"Back (Share)": vg.XUSB_BUTTON.XUSB_GAMEPAD_BACK,
"Start (Options)": vg.XUSB_BUTTON.XUSB_GAMEPAD_START,
"LThumb (L3)": vg.XUSB_BUTTON.XUSB_GAMEPAD_LEFT_THUMB,
"RThumb (R3)": vg.XUSB_BUTTON.XUSB_GAMEPAD_RIGHT_THUMB,
"Guide (PS)": vg.XUSB_BUTTON.XUSB_GAMEPAD_GUIDE,
"DPad Up": vg.XUSB_BUTTON.XUSB_GAMEPAD_DPAD_UP,
"DPad Down": vg.XUSB_BUTTON.XUSB_GAMEPAD_DPAD_DOWN,
"DPad Left": vg.XUSB_BUTTON.XUSB_GAMEPAD_DPAD_LEFT,
"DPad Right": vg.XUSB_BUTTON.XUSB_GAMEPAD_DPAD_RIGHT,
"--- KEYBOARD KEYS ---": None,
}
# Keyboard key mapping (only if pynput is available)
if KEYBOARD_AVAILABLE:
KEYBOARD_MAP = {
"Key: Space": ' ',
"Key: Enter": Key.enter,
"Key: Shift": Key.shift,
"Key: Ctrl": Key.ctrl,
"Key: Alt": Key.alt,
"Key: Tab": Key.tab,
"Key: Esc": Key.esc,
"Key: 0": '0', "Key: 1": '1', "Key: 2": '2', "Key: 3": '3', "Key: 4": '4',
"Key: 5": '5', "Key: 6": '6', "Key: 7": '7', "Key: 8": '8', "Key: 9": '9',
"Key: A": 'a', "Key: B": 'b', "Key: C": 'c', "Key: D": 'd', "Key: E": 'e',
"Key: F": 'f', "Key: G": 'g', "Key: H": 'h', "Key: I": 'i', "Key: J": 'j',
"Key: K": 'k', "Key: L": 'l', "Key: M": 'm', "Key: N": 'n', "Key: O": 'o',
"Key: P": 'p', "Key: Q": 'q', "Key: R": 'r', "Key: S": 's', "Key: T": 't',
"Key: U": 'u', "Key: V": 'v', "Key: W": 'w', "Key: X": 'x', "Key: Y": 'y',
"Key: Z": 'z',
"Key: F1": Key.f1, "Key: F2": Key.f2, "Key: F3": Key.f3, "Key: F4": Key.f4,
"Key: F5": Key.f5, "Key: F6": Key.f6, "Key: F7": Key.f7, "Key: F8": Key.f8,
"Key: F9": Key.f9, "Key: F10": Key.f10, "Key: F11": Key.f11, "Key: F12": Key.f12,
"Key: Up Arrow": Key.up,
"Key: Down Arrow": Key.down,
"Key: Left Arrow": Key.left,
"Key: Right Arrow": Key.right,
}
else:
KEYBOARD_MAP = {}
QML_CODE = """
import QtQuick
import QtQuick.Controls.Basic
import QtQuick.Layouts
import QtQuick.Window
ApplicationWindow {
id: mainWindow
width: 520
height: 1120
visible: true
title: "TrueAxis v3.5"
color: "#09090b"
// Auto-start minimized flag
property bool autoStartMinimized: false
// Premium Silver/Gray Color Palette - Clean and professional
readonly property color colorPrimary: "#94a3b8"
readonly property color colorPrimaryHover: "#64748b"
readonly property color colorAccent: "#cbd5e1"
readonly property color colorBg: "#0a0a0f"
readonly property color colorSurface: "#0f0f16"
readonly property color colorSurfaceHover: "#17171f"
readonly property color colorCard: "#14141c"
readonly property color colorTextPrimary: "#f5f5f7"
readonly property color colorTextSecondary: "#cbd5e1"
readonly property color colorTextMuted: "#737380"
readonly property color colorBorder: "#26262f"
readonly property color colorBorderLight: "#3a3a45"
readonly property color colorSuccess: "#22c55e"
readonly property color colorWarning: "#f59e0b"
readonly property color colorError: "#ef4444"
readonly property color colorInactive: "#3a3a45"
// Silver gradient colors for axis bars
readonly property color hudSilver1: "#1e293b"
readonly property color hudSilver2: "#94a3b8"
readonly property color hudSilver3: "#475569"
property var silverGradient: [
{position: 0.0, color: hudSilver1},
{position: 0.5, color: hudSilver2},
{position: 1.0, color: hudSilver3}
]
// Handle close event - close application
onClosing: function(close) {
console.log("Closing application...")
// Stop emulation if running
if (backend.running) {
backend.stop_mapping()
}
// Save settings before quitting
backend.force_save_settings()
trayManager.save_settings()
close.accepted = true
Qt.quit()
}
// Handle window state changes
onVisibilityChanged: {
if (visibility === Window.Minimized && trayManager.hideToTrayEnabled) {
hide()
trayManager.show_tray()
}
}
Rectangle {
anchors.fill: parent
gradient: Gradient {
orientation: Gradient.Radial
GradientStop { position: 0.0; color: "#2d3748" }
GradientStop { position: 0.4; color: "#1a202c" }
GradientStop { position: 1.0; color: "#0a0a0f" }
}
}
Column {
id: mainColumn
anchors.fill: parent
spacing: 0
// HEADER - matching web demo layout
Item {
width: parent.width
height: 110
Column {
anchors.left: parent.left
anchors.leftMargin: 24
anchors.top: parent.top
anchors.topMargin: 28
spacing: 4
Row {
spacing: 12
Text {
text: "TrueAxis"
font.pixelSize: 32
font.bold: true
color: colorTextPrimary
font.family: "Inter"
}
// Version badge with silver gradient border
Rectangle {
width: 52
height: 28
radius: 8
anchors.verticalCenter: parent.verticalCenter
anchors.verticalCenterOffset: 4
gradient: Gradient {
GradientStop { position: 0.0; color: "#475569" }
GradientStop { position: 0.5; color: "#94a3b8" }
GradientStop { position: 1.0; color: "#475569" }
}
Text {
anchors.centerIn: parent
text: "v3.5"
font.pixelSize: 11
font.bold: true
color: "#e2e8f0"
font.family: "Inter"
}
}
// Spacer
Item { width: 1; Layout.fillWidth: true }
}
Text {
text: "Competitive"
font.pixelSize: 13
font.bold: true
color: "#94a3b8"
font.family: "Inter"
}
}
}
Rectangle {
width: parent.width - 48
height: 0
color: colorBorder
anchors.horizontalCenter: parent.horizontalCenter
}
// AUTOSTART & TRAY SETTINGS - Clean integrated design
SectionCard {
title: ""
cardHeight: autoStartCheckbox.checked ? 95 : 60
Column {
spacing: 10
width: parent.width
// Main settings row - clean and minimal
Row {
spacing: 12
width: parent.width
// Launch at startup
Rectangle {
width: 145
height: 34
radius: 8
color: autoStartCheckbox.checked ? colorSurface : "transparent"
border.width: 1
border.color: autoStartCheckbox.checked ? colorPrimary : colorBorder
Row {
anchors.centerIn: parent
spacing: 8
Rectangle {
width: 16
height: 16
radius: 8
color: autoStartCheckbox.checked ? colorPrimary : colorSurface
border.width: 1
border.color: colorBorder
anchors.verticalCenter: parent.verticalCenter
Rectangle {
width: 8
height: 8
radius: 4
color: colorTextPrimary
anchors.centerIn: parent
visible: autoStartCheckbox.checked
}
}
Text {
text: "Launch at startup"
font.pixelSize: 10
color: autoStartCheckbox.checked ? colorTextPrimary : colorTextMuted
anchors.verticalCenter: parent.verticalCenter
font.family: "Inter"
}
}
MouseArea {
id: autoStartCheckbox
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
property bool checked: false
onClicked: {
checked = !checked
backend.set_auto_start(checked)
}
}
Connections {
target: backend
function onAutoStartChanged(enabled) {
autoStartCheckbox.checked = enabled
}
}
}
// Start minimized
Rectangle {
width: 125
height: 34
radius: 8
color: startMinimizedCheckbox.checked ? colorSurface : "transparent"
border.width: 1
border.color: startMinimizedCheckbox.checked ? colorPrimary : colorBorder
opacity: autoStartCheckbox.checked ? 1.0 : 0.4
Row {
anchors.centerIn: parent
spacing: 8
Rectangle {
width: 16
height: 16
radius: 8
color: startMinimizedCheckbox.checked ? colorPrimary : colorSurface
border.width: 1
border.color: colorBorder
anchors.verticalCenter: parent.verticalCenter
Rectangle {
width: 8
height: 8
radius: 4
color: colorTextPrimary
anchors.centerIn: parent
visible: startMinimizedCheckbox.checked
}
}
Text {
text: "Start minimized"
font.pixelSize: 10
color: startMinimizedCheckbox.checked ? colorTextPrimary : colorTextMuted
anchors.verticalCenter: parent.verticalCenter
font.family: "Inter"
}
}
MouseArea {
id: startMinimizedCheckbox
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
property bool checked: false
enabled: autoStartCheckbox.checked
onClicked: {
if (enabled) {
checked = !checked
backend.set_start_minimized(checked)
}
}
}
Connections {
target: backend
function onStartMinimizedChanged(enabled) {
startMinimizedCheckbox.checked = enabled
}
}
}
// Minimize to tray
Rectangle {
width: 125
height: 34
radius: 8
color: hideToTrayCheckbox.checked ? colorSurface : "transparent"
border.width: 1
border.color: hideToTrayCheckbox.checked ? colorPrimary : colorBorder
opacity: trayManager.trayAvailable ? 1.0 : 0.4
Row {
anchors.centerIn: parent
spacing: 8
Rectangle {
width: 16
height: 16
radius: 8
color: hideToTrayCheckbox.checked ? colorPrimary : colorSurface
border.width: 1
border.color: colorBorder
anchors.verticalCenter: parent.verticalCenter
Rectangle {
width: 8
height: 8
radius: 4
color: colorTextPrimary
anchors.centerIn: parent
visible: hideToTrayCheckbox.checked
}
}
Text {
text: "Minimize to tray"
font.pixelSize: 10
color: hideToTrayCheckbox.checked ? colorTextPrimary : colorTextMuted
anchors.verticalCenter: parent.verticalCenter
font.family: "Inter"
}
}
MouseArea {
id: hideToTrayCheckbox
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
property bool checked: false
enabled: trayManager.trayAvailable
onClicked: {
if (enabled) {
checked = !checked
trayManager.set_hide_to_tray(checked)
}
}
}
Connections {
target: trayManager
function onHideToTrayChanged(enabled) {
hideToTrayCheckbox.checked = enabled
}
}
}
}
// Status indicator - shows when Launch at startup is enabled
Rectangle {
width: parent.width
height: 28
radius: 6
color: colorSurface
border.width: 1
border.color: colorBorder
visible: autoStartCheckbox.checked
Row {
anchors.centerIn: parent
spacing: 6
Rectangle {
width: 6
height: 6
radius: 3
color: colorSuccess
anchors.verticalCenter: parent.verticalCenter
}
Text {
text: startMinimizedCheckbox.checked ?
"Auto-start & minimize enabled" :
"Auto-start enabled"
font.pixelSize: 10
color: colorTextSecondary
font.family: "Inter"
anchors.verticalCenter: parent.verticalCenter
}
}
}
}
}
Item { width: 1; height: 20 }
// DEVICE SELECTION
SectionCard {
title: "Input Device"
cardHeight: 120
Column {
spacing: 10
width: parent.width
Row {
spacing: 10
width: parent.width
StyledComboBox {
id: deviceCombo
width: parent.width - 110
model: backend.devices
property bool updatingFromBackend: true // Start as true to prevent initial change
Component.onCompleted: {
// Set flag to false after a delay to allow proper initialization
updatingFromBackend = false
}
// Listen for device changes from backend
Connections {
target: backend
function onCurrentDeviceIndexChanged(index) {
deviceCombo.updatingFromBackend = true
deviceCombo.currentIndex = index
deviceCombo.updatingFromBackend = false
}
}
onCurrentIndexChanged: {
if (!updatingFromBackend && currentIndex >= 0) {
backend.select_device(currentIndex)
}
}
}
StyledButton {
text: "↻ Refresh"
width: 100
secondary: true
hoverEffect: true
onClicked: backend.refresh_devices()
}
}
StyledButton {
text: "Open Input Inspector"
width: parent.width
height: 32
secondary: true
fontSize: 11
hoverEffect: true
onClicked: inspectorDialog.open()
}
}
}
// PROFILE SELECTION
SectionCard {
title: "Profile"
cardHeight: 90
Column {
spacing: 8
width: parent.width
StyledComboBox {
id: profileCombo
width: parent.width
model: backend.profileNames
property bool updatingFromBackend: true // Start as true to prevent initial change
Component.onCompleted: {
// Set flag to false after a delay to allow proper initialization
updatingFromBackend = false
}
// Listen for profile changes from backend
Connections {
target: backend
function onCurrentProfileChanged(profileName) {
profileCombo.updatingFromBackend = true
var profileNames = backend.profileNames
for (var i = 0; i < profileNames.length; i++) {
if (profileNames[i] === profileName) {
profileCombo.currentIndex = i
break
}
}
profileCombo.updatingFromBackend = false
}
}
onCurrentTextChanged: {
if (!updatingFromBackend && currentText !== "") {
backend.select_profile(currentText)
}
}
}
Text {
id: profileDesc
text: "Select a profile to see description"
font.pixelSize: 11
color: colorTextMuted
wrapMode: Text.WordWrap
width: parent.width
font.family: "Inter"
}
}
}
// LIVE INPUT PREVIEW
SectionCard {
title: ""
cardHeight: 155
Column {
spacing: 0
width: parent.width
AxisBar {
id: steerBar
label: "Steering"
value: 0
direction: 0
gradientColors: silverGradient
}
Item { width: 1; height: 4 }
AxisBar {
id: gasBar
label: "Gas"
value: 0
gradientColors: silverGradient
}
Item { width: 1; height: 4 }
AxisBar {
id: brakeBar
label: "Brake"
value: 0
gradientColors: silverGradient
}
Item { width: 1; height: 10 }
Rectangle {
width: parent.width
height: 32
radius: 8
color: colorSurface
Row {
anchors.centerIn: parent
spacing: 20
StyledCheckBox {
id: invertGasCheckbox
text: "Invert Gas"
checked: false
Component.onCompleted: {
checked = backend.invert_gas
}
onToggled: backend.set_invert_gas(checked)
}
StyledCheckBox {
id: invertBrakeCheckbox
text: "Invert Brake"
checked: false
Component.onCompleted: {
checked = backend.invert_brake
}
onToggled: backend.set_invert_brake(checked)
}
}
}
}
}
// STEERING TUNING
SectionCard {
title: ""
cardHeight: 130
Column {
spacing: 10
width: parent.width
Row {
spacing: 10
width: parent.width
Text {
text: "Square Steering"
font.pixelSize: 11
font.bold: true
color: colorTextSecondary
width: 120
anchors.verticalCenter: parent.verticalCenter
font.family: "Inter"
}
StyledCheckBox {
id: squareCheckbox
text: ""
checked: false
anchors.verticalCenter: parent.verticalCenter
Component.onCompleted: {
checked = backend.square_input
squareSteeringRow.visible = checked
}
onToggled: {
backend.set_square_input(checked)
squareSteeringRow.visible = checked
}
}
Item { width: 1; Layout.fillWidth: true }
}
Row {
id: squareSteeringRow
spacing: 10
width: parent.width
visible: false
Text {
text: "Square Area:"
font.pixelSize: 11
color: colorTextMuted
width: 120
anchors.verticalCenter: parent.verticalCenter
font.family: "Inter"
}
Rectangle {
id: slider
width: parent.width - 120 - 50
height: 24
radius: 12
color: colorSurface
property real from: 0.0
property real to: 0.99
property real value: 0.5
property real stepSize: 0.01
Component.onCompleted: {
value = backend.square_area
}
Rectangle {
width: parent.width * ((parent.value - parent.from) / (parent.to - parent.from))
height: parent.height
radius: 12
color: colorPrimary
}
Rectangle {
x: (parent.width * ((parent.value - parent.from) / (parent.to - parent.from))) - 8
y: (parent.height - 16) / 2
width: 16
height: 16
radius: 8
color: colorTextPrimary
border.color: colorPrimary
border.width: 2
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
function updateValue(x) {
var newValue = slider.from + (x / width) * (slider.to - slider.from)
newValue = Math.max(slider.from, Math.min(slider.to, newValue))
if (slider.stepSize > 0) {
newValue = Math.round(newValue / slider.stepSize) * slider.stepSize
}
if (newValue !== slider.value) {
slider.value = newValue
backend.set_square_area(newValue)
}
}
onPressed: updateValue(mouse.x)
onPositionChanged: if (pressed) updateValue(mouse.x)
}
}
Text {
text: Math.round(slider.value * 100) + "%"
font.pixelSize: 11
color: colorTextMuted
width: 40
anchors.verticalCenter: parent.verticalCenter
font.family: "Inter"
}
}
Text {
id: squareDesc
text: "Standard circular steering response"
font.pixelSize: 10
color: colorTextMuted
width: parent.width
wrapMode: Text.WordWrap
font.family: "Inter"
Connections {
target: squareCheckbox
function onToggled() {
if (squareCheckbox.checked) {
squareDesc.text = "Steering will reach 100% at " + Math.round(slider.value * 100) + "% input"
} else {
squareDesc.text = "Standard circular steering response"
}
}
}
Connections {
target: slider
function onValueChanged() {
if (squareCheckbox.checked) {
squareDesc.text = "Steering will reach 100% at " + Math.round(slider.value * 100) + "% input"
}
}
}
}
}
}
// BUTTON MAPPING
SectionCard {
title: ""
cardHeight: 100
Column {
spacing: 10
width: parent.width
StyledButton {
text: "Configure Buttons"
width: parent.width
secondary: true
hoverEffect: true
onClicked: buttonDialog.open()
}
Text {
id: buttonStatus
text: "No buttons mapped"
font.pixelSize: 11
color: colorTextMuted
font.family: "Inter"
}
}
}
// ACTIVATE BUTTON
Item {
width: parent.width
height: 95
Column {
anchors.left: parent.left
anchors.leftMargin: 24
anchors.right: parent.right
anchors.rightMargin: 24
spacing: 12
Row {
spacing: 6
Text {
id: statusDot
text: "●"
font.pixelSize: 12
color: colorInactive
}
Text {
id: statusText
text: "Ready to activate"
font.pixelSize: 11
color: colorTextMuted
font.family: "Inter"
}
}
StyledButton {
id: activateBtn
text: backend.running ? "DEACTIVATE" : "ACTIVATE"
width: parent.width
height: 48
primary: !backend.running
danger: backend.running
fontSize: 14
hoverEffect: true
onClicked: backend.toggle_mapping()
}
}
}
Item { width: 1; height: 20 }
}
// INPUT INSPECTOR DIALOG
Dialog {
id: inspectorDialog
width: 420
height: 550
anchors.centerIn: parent
modal: true
title: "Input Inspector"
background: Rectangle {
color: colorBg
radius: 12
border.color: colorBorder
border.width: 1
}
header: Item {
height: 70
Column {
anchors.left: parent.left
anchors.leftMargin: 20
anchors.top: parent.top
anchors.topMargin: 18
spacing: 6
Text {
text: "Input Inspector"
font.pixelSize: 18
font.weight: Font.DemiBold
color: colorTextPrimary
font.family: "Inter"
}
Text {
text: "Identify axis IDs by moving controls"
font.pixelSize: 12
color: colorTextMuted
font.family: "Inter"
}
}
}
contentItem: ScrollView {
clip: true
Column {
spacing: 6
width: parent.width
Repeater {
model: backend.numAxes
Rectangle {
width: 380
height: 50
radius: 8
color: colorCard
border.color: colorBorder
border.width: 1
property int axisIndex: index
property real axisValue: 0.5
Component.onCompleted: {
axisValue = backend.getAxisValue(index)
}
Connections {
target: backend
function onAxisValuesChanged() {
axisValue = backend.getAxisValue(axisIndex)
}
}
Row {
anchors.fill: parent
anchors.margins: 12
spacing: 0
Text {
text: "Axis " + parent.parent.axisIndex
font.pixelSize: 11
font.bold: true
color: colorTextSecondary
width: 50
anchors.verticalCenter: parent.verticalCenter
font.family: "Inter"
}
Item {
width: parent.width - 50 - 55
height: parent.height
anchors.verticalCenter: parent.verticalCenter
Rectangle {
id: axisBarBg
anchors.fill: parent
anchors.leftMargin: 5
anchors.rightMargin: 5
height: 8
radius: 4
color: colorSurface
anchors.verticalCenter: parent.verticalCenter
clip: true
Rectangle {
id: axisBarFill
width: axisBarBg.width * Math.max(0, Math.min(1, axisValue))
height: parent.height
radius: 4
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: hudSilver1 }
GradientStop { position: 0.5; color: hudSilver2 }
GradientStop { position: 1.0; color: hudSilver3 }
}
Behavior on width {
NumberAnimation { duration: 50; easing.type: Easing.OutQuad }
}
}
}
}
Text {
id: axisValueText
text: Math.round(Math.max(0, Math.min(1, axisValue)) * 100) + "%"
font.pixelSize: 10
color: colorTextMuted
width: 45
anchors.verticalCenter: parent.verticalCenter
horizontalAlignment: Text.AlignRight
font.family: "Inter"
}
}
}
}
}
}
footer: DialogButtonBox {
padding: 16
background: Rectangle {
color: colorBg
border.color: colorBorder
border.width: 1
}
StyledButton {
text: "Close"
secondary: true
hoverEffect: true
onClicked: inspectorDialog.close()
}
}
}
// BUTTON MAPPING DIALOG
Dialog {
id: buttonDialog
width: 480
height: 550
anchors.centerIn: parent
modal: true
title: "Button Mapping"
background: Rectangle {
color: colorBg
radius: 12
border.color: colorBorder
border.width: 1
}
header: Item {
height: 70
Column {
anchors.left: parent.left
anchors.leftMargin: 20
anchors.top: parent.top
anchors.topMargin: 18
spacing: 6
Text {
text: "Button Mapping"
font.pixelSize: 18
font.weight: Font.DemiBold
color: colorTextPrimary
font.family: "Inter"
}
Text {
text: backend.numButtons + " buttons available"
font.pixelSize: 12
color: colorTextMuted
font.family: "Inter"
}
}
}
contentItem: Column {
spacing: 8
ScrollView {
width: parent.width
height: 390
clip: true
Column {
spacing: 2
width: parent.width
Repeater {
model: backend.numButtons
Rectangle {
width: 440
height: 44
radius: 6
color: colorCard
border.color: colorBorder
border.width: 1
property int buttonIndex: index
property bool buttonPressed: false
Component.onCompleted: {
buttonPressed = backend.getButtonState(index)
}
Connections {
target: backend
function onButtonStatesChanged() {
buttonPressed = backend.getButtonState(buttonIndex)
}
}
Row {
anchors.centerIn: parent
spacing: 10
width: parent.width - 20
Rectangle {
width: 18
height: 18
radius: 9
color: parent.parent.buttonPressed ? colorAccent : colorInactive
border.color: parent.parent.buttonPressed ? "#1d4ed8" : colorBorder
border.width: 2
anchors.verticalCenter: parent.verticalCenter
Rectangle {
anchors.centerIn: parent
width: 6
height: 6
radius: 3
color: parent.parent.parent.parent.buttonPressed ? "#ffffff" : "transparent"
visible: parent.parent.parent.parent.buttonPressed
}
Behavior on color {
ColorAnimation { duration: 100 }
}
Behavior on border.color {
ColorAnimation { duration: 100 }
}
}
Text {
text: "BTN " + parent.parent.buttonIndex
font.pixelSize: 12
font.weight: Font.Medium
color: colorTextSecondary
width: 55
anchors.verticalCenter: parent.verticalCenter
font.family: "Inter"
}
Item { width: 1; Layout.fillWidth: true }
StyledComboBox {
width: 180
height: 30
model: backend.xboxButtonNames
currentIndex: {
var mapping = backend.get_button_mapping(buttonIndex)
return model.indexOf(mapping)
}
onCurrentTextChanged: {
backend.set_button_mapping(buttonIndex, currentText)
}
}
}
}
}
}
}
Row {
spacing: 8
width: parent.width
StyledButton {
text: "Save & Close"
width: parent.width * 0.65
height: 40
primary: true
hoverEffect: true
onClicked: {
backend.save_button_mapping()
buttonDialog.close()
}
}
StyledButton {
text: "Cancel"
width: parent.width * 0.35 - 8
height: 40
secondary: true
hoverEffect: true
onClicked: buttonDialog.close()
}
}
}
}
// CONNECTIONS
Connections {
target: backend
function onSteeringChanged(value) {
steerBar.value = value
// Convert 0-1 value to -1 to 1 for direction
steerBar.direction = (value * 2) - 1
}
function onGasChanged(value) {
gasBar.value = value
}
function onBrakeChanged(value) {
brakeBar.value = value
}
function onStatusChanged(text, color) {
statusText.text = text
statusText.color = color
statusDot.color = color
}
function onProfileDescChanged(desc) {
profileDesc.text = desc
}
function onButtonStatusChanged(status) {
buttonStatus.text = status
}
function onAutoStartChanged(enabled) {
autoStartCheckbox.checked = enabled
}
function onStartMinimizedChanged(enabled) {
startMinimizedCheckbox.checked = enabled
}
}
Connections {
target: trayManager
function onHideToTrayEnabledChanged(enabled) {
hideToTrayCheckbox.checked = enabled
}
function onTrayAvailableChanged(available) {
hideToTrayCheckbox.enabled = available
}
}
// CUSTOM COMPONENTS
component SectionCard: Item {
property string title: ""
property string subtitle: ""
property int cardHeight: 100
default property alias content: contentArea.children
width: parent.width
height: headerRow.height + card.height + 20
Row {
id: headerRow
anchors.left: parent.left
anchors.leftMargin: 24
anchors.top: parent.top
spacing: 10
Text {
text: title
font.pixelSize: 14
font.bold: true
color: colorTextPrimary
font.family: "Inter"
}
Text {
text: " " + subtitle
font.pixelSize: 11
color: colorTextMuted
font.family: "Inter"
}
}
Rectangle {
id: card
anchors.top: headerRow.bottom
anchors.topMargin: 8
anchors.left: parent.left
anchors.leftMargin: 24
anchors.right: parent.right
anchors.rightMargin: 24
height: cardHeight
radius: 12
color: colorCard
border.color: colorBorder
border.width: 1
Item {
id: contentArea
anchors.fill: parent
anchors.margins: 16
}
}
}
component AxisBar: Item {
property string label: ""
property color barColor: colorPrimary
property real value: 0.0
property real direction: 0.0 // -1 to 1 for steering direction
property var gradientColors: mainWindow.tealGradient
width: parent.width
height: 24
Row {
anchors.fill: parent
spacing: 0
Text {
text: label
font.pixelSize: 11
font.bold: true
color: colorTextSecondary
width: 70
anchors.verticalCenter: parent.verticalCenter
font.family: "Inter"
}
Item {
width: parent.width - 70 - 40
height: parent.height
anchors.verticalCenter: parent.verticalCenter
Rectangle {
id: axisBarBg
anchors.fill: parent
anchors.leftMargin: 5
anchors.rightMargin: 5
height: 8
radius: 4
color: colorSurface
anchors.verticalCenter: parent.verticalCenter
clip: true
Rectangle {
width: parent.width * Math.max(0, Math.min(1, value))
height: parent.height
radius: 4
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: gradientColors[0].position; color: gradientColors[0].color }
GradientStop { position: gradientColors[1].position; color: gradientColors[1].color }
GradientStop { position: gradientColors[2].position; color: gradientColors[2].color }
}
Behavior on width {
NumberAnimation { duration: 50; easing.type: Easing.OutQuad }
}
}
}
}
Text {
text: Math.round(Math.max(0, Math.min(1, value)) * 100) + "%"
font.pixelSize: 10
color: colorTextMuted
width: 30
anchors.verticalCenter: parent.verticalCenter
horizontalAlignment: Text.AlignRight
font.family: "Inter"
}
}
}
component StyledButton: Rectangle {
id: btn
property string text: ""
property bool primary: false
property bool danger: false
property bool secondary: false
property bool hoverEffect: false
property int fontSize: 12
signal clicked()
width: 100
height: 38
radius: secondary ? 8 : 10
color: {
if (danger) return btnMouse.containsMouse && hoverEffect ? "#dc2626" : colorError
if (primary) return btnMouse.containsMouse && hoverEffect ? colorPrimaryHover : colorPrimary
return btnMouse.containsMouse && hoverEffect ? colorSurfaceHover : colorSurface
}
border.color: secondary ? colorBorder : "transparent"
border.width: secondary ? 1 : 0
Text {
anchors.centerIn: parent
text: btn.text
font.pixelSize: fontSize
font.bold: true
color: primary || danger ? colorBg : (secondary ? colorTextSecondary : colorTextPrimary)
font.family: "Inter"
}
MouseArea {
id: btnMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: btn.clicked()
onPressed: {
if (hoverEffect) {
btn.scale = 0.98
}
}
onReleased: {
if (hoverEffect) {
btn.scale = 1.0
}
}
onEntered: {
if (hoverEffect) {
btn.z = 1
}
}
onExited: {
if (hoverEffect) {
btn.z = 0
btn.scale = 1.0
}
}
}
Behavior on color {
ColorAnimation { duration: 150 }
}
Behavior on scale {
NumberAnimation { duration: 100; easing.type: Easing.OutQuad }
}
}
component StyledComboBox: Rectangle {
id: combo
property var model: []
property int currentIndex: 0
property string currentText: model[currentIndex] || ""
width: 200
height: 38
radius: 8
color: comboMouse.containsMouse ? colorSurfaceHover : colorSurface
border.color: colorBorder
border.width: 1
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: combo.currentText
font.pixelSize: 12
color: colorTextPrimary
font.family: "Inter"
elide: Text.ElideRight
width: parent.width - 40
}
Text {
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: "▼"
font.pixelSize: 8
color: colorTextMuted
}
MouseArea {
id: comboMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: popup.open()
}
Popup {
id: popup
y: parent.height + 4
width: parent.width
height: Math.min(listView.contentHeight + 8, 300)
padding: 4
background: Rectangle {
color: colorSurface
radius: 8
border.color: colorBorder
border.width: 1
}
contentItem: ListView {
id: listView
clip: true
model: combo.model
currentIndex: combo.currentIndex
delegate: Rectangle {
width: ListView.view.width
height: 32
color: delegateMouse.containsMouse ? colorSurfaceHover : "transparent"
radius: 6
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: modelData
font.pixelSize: 12
color: colorTextPrimary
font.family: "Inter"
}
MouseArea {
id: delegateMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
combo.currentIndex = index
popup.close()
}
}
}
}
}
Behavior on color {
ColorAnimation { duration: 150 }
}
}
component StyledCheckBox: Item {
property string text: ""
property bool checked: false
property bool enabled: true
signal toggled()
width: checkRow.width
height: 20
Row {
id: checkRow
spacing: 8
Rectangle {
width: 16
height: 16
radius: 4
color: checked && enabled ? colorPrimary : "transparent"
border.color: enabled ? (checked ? colorPrimary : colorBorderLight) : colorInactive
border.width: 1
anchors.verticalCenter: parent.verticalCenter
Text {
anchors.centerIn: parent
text: "✓"
font.pixelSize: 10
font.bold: true
color: colorBg
visible: checked && enabled
}
Behavior on color {
ColorAnimation { duration: 150 }
}
Behavior on border.color {
ColorAnimation { duration: 150 }
}
}
Text {
text: parent.parent.text
font.pixelSize: 11
color: enabled ? colorTextSecondary : colorInactive
anchors.verticalCenter: parent.verticalCenter
font.family: "Inter"
}
}
MouseArea {
anchors.fill: parent
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: {
if (enabled) {
checked = !checked
toggled()
}
}
}
}
}
"""
class SystemTrayManager(QObject):
"""Manages system tray functionality for TrueAxis"""
hideToTrayEnabledChanged = Signal(bool)
trayAvailableChanged = Signal(bool)
def __init__(self, main_window=None, backend=None):
super().__init__()
self.main_window = main_window
self.backend = backend
self.tray_icon = None
self._hide_to_tray_enabled = False
self._tray_available = False
self.setup_tray()
@Property(bool, notify=hideToTrayEnabledChanged)
def hideToTrayEnabled(self):
return self._hide_to_tray_enabled
@Property(bool, notify=trayAvailableChanged)
def trayAvailable(self):
return self._tray_available
@Slot(bool)
def set_hide_to_tray(self, enabled):
self._hide_to_tray_enabled = enabled
self.hideToTrayEnabledChanged.emit(enabled)
self.save_settings()
def setup_tray(self):
"""Setup system tray icon and menu"""
if QSystemTrayIcon.isSystemTrayAvailable():
self._tray_available = True
self.trayAvailableChanged.emit(True)
# Create tray icon
self.tray_icon = QSystemTrayIcon()
# Create a simple icon for TrueAxis
icon = self.create_trueaxis_icon()
self.tray_icon.setIcon(icon)
self.tray_icon.setToolTip("TrueAxis - Running in background")
# Create tray menu
tray_menu = QMenu()
# Show/Hide action
self.show_action = QAction("Show TrueAxis", tray_menu)
self.show_action.triggered.connect(self.show_main_window)
tray_menu.addAction(self.show_action)
tray_menu.addSeparator()
# Exit action
exit_action = QAction("Exit", tray_menu)
exit_action.triggered.connect(self.quit_application)
tray_menu.addAction(exit_action)
self.tray_icon.setContextMenu(tray_menu)
# Connect tray icon click
self.tray_icon.activated.connect(self.on_tray_activated)
else:
self._tray_available = False
self.trayAvailableChanged.emit(False)
print("System tray is not available on this system")
def create_trueaxis_icon(self):
"""Create a TrueAxis tray icon"""
pixmap = QPixmap(32, 32)
pixmap.fill(Qt.GlobalColor.transparent)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.Antialiasing)
# Draw a T for TrueAxis in circle
painter.setBrush(QColor(16, 185, 129)) # Primary color
painter.setPen(Qt.PenStyle.NoPen)
painter.drawEllipse(4, 4, 24, 24)
# Draw T
painter.setPen(QColor(255, 255, 255))
painter.setFont(QFont("Inter", 16, QFont.Bold))
painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "T")
painter.end()
return QIcon(pixmap)
@Slot()
def show_main_window(self):
"""Show the main window from tray"""
if self.main_window:
self.main_window.show()
self.main_window.raise_()
self.main_window.requestActivate()
self.hide_tray()
if self.show_action:
self.show_action.setText("Show TrueAxis")
@Slot()
def hide_main_window(self):
"""Hide the main window to tray"""
if self.main_window:
self.main_window.hide()
@Slot()
def show_tray(self):
"""Show the tray icon"""
if self.tray_icon and self._tray_available:
self.tray_icon.show()
@Slot()
def hide_tray(self):
"""Hide the tray icon"""
if self.tray_icon:
self.tray_icon.hide()
def on_tray_activated(self, reason):
"""Handle tray icon activation"""
if reason == QSystemTrayIcon.ActivationReason.DoubleClick:
self.show_main_window()
elif reason == QSystemTrayIcon.ActivationReason.Trigger:
pass # Single click - do nothing
def quit_application(self):
"""Quit the application"""
app = QApplication.instance()
if app:
app.quit()
@Slot()
def save_settings(self):
"""Save tray settings"""
# Don't save during backend initialization
if self.backend and hasattr(self.backend, '_initializing') and self.backend._initializing:
return
try:
settings = {}
if os.path.exists(SETTINGS_FILE):
with open(SETTINGS_FILE, 'r') as f:
settings = json.load(f)
settings['tray_settings'] = {
'hide_to_tray': self._hide_to_tray_enabled
}
with open(SETTINGS_FILE, 'w') as f:
json.dump(settings, f, indent=2)
except Exception as e:
print(f"Error saving tray settings: {e}")
def load_settings(self):
"""Load tray settings"""
try:
if os.path.exists(SETTINGS_FILE):
with open(SETTINGS_FILE, 'r') as f:
settings = json.load(f)
if 'tray_settings' in settings:
tray_settings = settings['tray_settings']
self._hide_to_tray_enabled = tray_settings.get('hide_to_tray', False)
self.hideToTrayEnabledChanged.emit(self._hide_to_tray_enabled)
except:
pass
class AutoStartManager(QObject):
"""Manages auto-start with Windows functionality"""
def __init__(self):
super().__init__()
self.app_name = "TrueAxis"
self.app_path = os.path.abspath(sys.argv[0])
# Check if running as PyInstaller executable
if getattr(sys, 'frozen', False):
self.app_path = sys.executable
def enable_auto_start(self):
"""Enable auto-start with Windows"""
try:
if sys.platform == 'win32':
return self._enable_windows_auto_start()
elif sys.platform == 'darwin': # macOS
return self._enable_macos_auto_start()
elif sys.platform == 'linux':
return self._enable_linux_auto_start()
else:
print(f"Auto-start not supported on platform: {sys.platform}")
return False
except Exception as e:
print(f"Error enabling auto-start: {e}")
return False
def disable_auto_start(self):
"""Disable auto-start with Windows"""
try:
if sys.platform == 'win32':
return self._disable_windows_auto_start()
elif sys.platform == 'darwin': # macOS
return self._disable_macos_auto_start()
elif sys.platform == 'linux':
return self._disable_linux_auto_start()
else:
print(f"Auto-start not supported on platform: {sys.platform}")
return False
except Exception as e:
print(f"Error disabling auto-start: {e}")
return False
def is_auto_start_enabled(self):
"""Check if auto-start is enabled"""
try:
if sys.platform == 'win32':
return self._is_windows_auto_start_enabled()
elif sys.platform == 'darwin': # macOS
return self._is_macos_auto_start_enabled()
elif sys.platform == 'linux':
return self._is_linux_auto_start_enabled()
else:
return False
except Exception as e:
print(f"Error checking auto-start: {e}")
return False
def _enable_windows_auto_start(self):
"""Enable auto-start on Windows using registry"""
try:
import winreg
key = winreg.HKEY_CURRENT_USER
sub_key = r"Software\Microsoft\Windows\CurrentVersion\Run"
with winreg.OpenKey(key, sub_key, 0, winreg.KEY_WRITE) as reg_key:
winreg.SetValueEx(reg_key, self.app_name, 0, winreg.REG_SZ, f'"{self.app_path}" --minimized')
return True
except Exception as e:
print(f"Windows registry error: {e}")
return False
def _disable_windows_auto_start(self):
"""Disable auto-start on Windows"""
try:
import winreg
key = winreg.HKEY_CURRENT_USER
sub_key = r"Software\Microsoft\Windows\CurrentVersion\Run"
with winreg.OpenKey(key, sub_key, 0, winreg.KEY_WRITE) as reg_key:
winreg.DeleteValue(reg_key, self.app_name)
return True
except Exception as e:
print(f"Windows registry error: {e}")
return False
def _is_windows_auto_start_enabled(self):
"""Check if auto-start is enabled on Windows"""
try:
import winreg
key = winreg.HKEY_CURRENT_USER
sub_key = r"Software\Microsoft\Windows\CurrentVersion\Run"
with winreg.OpenKey(key, sub_key, 0, winreg.KEY_READ) as reg_key:
value, _ = winreg.QueryValueEx(reg_key, self.app_name)
return value is not None
except WindowsError:
return False
except Exception as e:
print(f"Windows registry error: {e}")
return False
def _enable_macos_auto_start(self):
"""Enable auto-start on macOS using launchd"""
try:
launchd_dir = Path.home() / "Library" / "LaunchAgents"
launchd_dir.mkdir(parents=True, exist_ok=True)
plist_content = {
'Label': f'com.trueaxis.{self.app_name.lower()}',
'Program': self.app_path,
'RunAtLoad': True,
'KeepAlive': False,
'WorkingDirectory': os.path.dirname(self.app_path),
}
plist_path = launchd_dir / f'com.trueaxis.{self.app_name.lower()}.plist'
with open(plist_path, 'wb') as f:
plistlib.dump(plist_content, f)
# Load the launch agent
subprocess.run(['launchctl', 'load', str(plist_path)])
return True
except Exception as e:
print(f"macOS launchd error: {e}")
return False
def _disable_macos_auto_start(self):
"""Disable auto-start on macOS"""
try:
plist_path = Path.home() / "Library" / "LaunchAgents" / f'com.trueaxis.{self.app_name.lower()}.plist'
if plist_path.exists():
# Unload the launch agent
subprocess.run(['launchctl', 'unload', str(plist_path)])
plist_path.unlink()
return True
except Exception as e:
print(f"macOS launchd error: {e}")
return False
def _is_macos_auto_start_enabled(self):
"""Check if auto-start is enabled on macOS"""
try:
plist_path = Path.home() / "Library" / "LaunchAgents" / f'com.trueaxis.{self.app_name.lower()}.plist'
return plist_path.exists()
except:
return False
def _enable_linux_auto_start(self):
"""Enable auto-start on Linux using .desktop file"""
try:
autostart_dir = Path.home() / ".config" / "autostart"
autostart_dir.mkdir(parents=True, exist_ok=True)
desktop_content = f"""[Desktop Entry]
Type=Application
Name={self.app_name}
Exec={self.app_path} --minimized
Comment=TrueAxis Application
Categories=Utility;
StartupNotify=false
Terminal=false
Hidden=false
"""
desktop_path = autostart_dir / f"{self.app_name.lower()}.desktop"
with open(desktop_path, 'w') as f:
f.write(desktop_content)
# Make it executable
os.chmod(desktop_path, 0o755)
return True
except Exception as e:
print(f"Linux autostart error: {e}")
return False
def _disable_linux_auto_start(self):
"""Disable auto-start on Linux"""
try:
desktop_path = Path.home() / ".config" / "autostart" / f"{self.app_name.lower()}.desktop"
if desktop_path.exists():
desktop_path.unlink()
return True
except Exception as e:
print(f"Linux autostart error: {e}")
return False
def _is_linux_auto_start_enabled(self):
"""Check if auto-start is enabled on Linux"""
try:
desktop_path = Path.home() / ".config" / "autostart" / f"{self.app_name.lower()}.desktop"
return desktop_path.exists()
except:
return False
class InputReader(QThread):
"""Separate thread for reading input to prevent UI lag"""
axisValuesChanged = Signal(list)
buttonStatesChanged = Signal(list)
def __init__(self):
super().__init__()
self.joystick = None
self._running = True
def set_joystick(self, joystick):
self.joystick = joystick
def run(self):
while self._running:
try:
if self.joystick:
pygame.event.pump()
# Read axis values
axis_values = []
num_axes = self.joystick.get_numaxes()
for i in range(num_axes):
try:
raw = self.joystick.get_axis(i)
axis_values.append((raw + 1) / 2)
except:
axis_values.append(0.5) # Default center position
# Read button states
button_states = []
num_buttons = self.joystick.get_numbuttons()
for i in range(num_buttons):
try:
button_states.append(bool(self.joystick.get_button(i)))
except:
button_states.append(False)
# Emit signals
self.axisValuesChanged.emit(axis_values)
self.buttonStatesChanged.emit(button_states)
# Small delay to prevent CPU overload
time.sleep(0.01)
except Exception as e:
print(f"Error in input reader: {e}")
time.sleep(1)
def stop(self):
self._running = False
self.wait()
class TrueAxisBackend(QObject):
devicesChanged = Signal(list)
profileDescChanged = Signal(str)
currentProfileChanged = Signal(str)
currentDeviceIndexChanged = Signal(int)
invertGasChanged = Signal(bool)
invertBrakeChanged = Signal(bool)
squareInputChanged = Signal(bool)
squareAreaChanged = Signal(float)
steeringChanged = Signal(float)
gasChanged = Signal(float)
brakeChanged = Signal(float)
statusChanged = Signal(str, str)
isRunningChanged = Signal(bool)
buttonStatusChanged = Signal(str)
axisValuesChanged = Signal()
buttonStatesChanged = Signal()
numAxesChanged = Signal()
numButtonsChanged = Signal()
autoStartChanged = Signal(bool)
startMinimizedChanged = Signal(bool)
def __init__(self):
super().__init__()
self._running = False
self._devices = []
self._current_device_index = 0
self._current_profile = "Logitech G29 (Standard)"
self._invert_gas = False
self._invert_brake = False
self._square_input = False
self._square_area = 0.5 # Default: 50% square area
self._auto_start = False
self._start_minimized = False
self._initializing = True # Flag to prevent saving during initialization
self.mapper_thread = None
self.input_reader = None
self.vg_gamepad = None
self.joystick = None
self.button_mapping = {}
self._axis_values = []
self._button_states = []
# Initialize keyboard controller if available
if KEYBOARD_AVAILABLE:
self.keyboard = KeyboardController()
self._pressed_keys = set() # Track currently pressed keyboard keys
else:
self.keyboard = None
self._pressed_keys = set()
# Initialize auto-start manager
self.auto_start_manager = AutoStartManager()
# Initialize pygame for joystick support
pygame.init()
pygame.joystick.init()
# Load configuration
self.load_button_mapping()
self.load_settings()
# Create input reader thread
self.input_reader = InputReader()
self.input_reader.axisValuesChanged.connect(self.update_axis_values)
self.input_reader.buttonStatesChanged.connect(self.update_button_states)
self.input_reader.start()
# Initialize tray manager
self.tray_manager = SystemTrayManager(backend=self)
self.tray_manager.load_settings()
# Start update timer for processing inputs
self.update_timer = QTimer()
self.update_timer.timeout.connect(self.process_inputs)
self.update_timer.start(16) # ~60 FPS for smooth updates
# Initial device refresh
self.refresh_devices()
# Restore saved device selection after a short delay (to let devices enumerate)
if self._current_device_index >= 0:
QTimer.singleShot(800, self.restore_device_selection)
# Mark initialization as complete after a delay (to let QML finish loading)
QTimer.singleShot(1000, lambda: setattr(self, '_initializing', False))
@Property(bool, notify=isRunningChanged)
def running(self):
return self._running
@Property(list, notify=devicesChanged)
def devices(self):
return self._devices
@Property(list, constant=True)
def profileNames(self):
return list(PROFILES.keys())
@Property(str, notify=currentProfileChanged)
def current_profile(self):
return self._current_profile
@Property(int, notify=currentDeviceIndexChanged)
def current_device_index(self):
return self._current_device_index
@Property(bool, notify=invertGasChanged)
def invert_gas(self):
return self._invert_gas
@Property(bool, notify=invertBrakeChanged)
def invert_brake(self):
return self._invert_brake
@Property(bool, notify=squareInputChanged)
def square_input(self):
return self._square_input
@Property(float, notify=squareAreaChanged)
def square_area(self):
return self._square_area
@Property(list, constant=True)
def xboxButtonNames(self):
return XBOX_BUTTON_NAMES
@Property(bool, constant=True)
def keyboardAvailable(self):
return KEYBOARD_AVAILABLE
@Property(int, notify=numAxesChanged)
def numAxes(self):
if self.joystick:
try:
return self.joystick.get_numaxes()
except:
pass
return 0
@Property(int, notify=numButtonsChanged)
def numButtons(self):
if self.joystick:
try:
return self.joystick.get_numbuttons()
except:
pass
return 0
@Slot(int, result=float)
def getAxisValue(self, index):
try:
if index < len(self._axis_values):
value = float(self._axis_values[index])
# Ensure value is a valid number
if not (value >= 0 and value <= 1):
value = max(0.0, min(1.0, value))
return value
except (ValueError, TypeError):
pass
return 0.5
@Slot(int, result=bool)
def getButtonState(self, index):
if index < len(self._button_states):
return bool(self._button_states[index])
return False
@Slot(int, result=str)
def get_button_mapping(self, button_index):
return self.button_mapping.get(str(button_index), "DISABLED")
@Slot(int, str)
def set_button_mapping(self, button_index, xbox_button):
# Ignore section headers
if xbox_button in ["--- XBOX BUTTONS ---", "--- KEYBOARD KEYS ---"]:
return
if xbox_button == "DISABLED":
if str(button_index) in self.button_mapping:
del self.button_mapping[str(button_index)]
else:
self.button_mapping[str(button_index)] = xbox_button
# Auto-save button mapping changes
self.save_button_mapping()
@Slot()
def save_button_mapping(self):
# Don't save during initialization to prevent overwriting loaded settings
if hasattr(self, '_initializing') and self._initializing:
return
try:
with open(BUTTON_MAPPING_FILE, 'w') as f:
json.dump(self.button_mapping, f, indent=2)
self.update_button_status()
except:
pass
def load_button_mapping(self):
if os.path.exists(BUTTON_MAPPING_FILE):
try:
with open(BUTTON_MAPPING_FILE, 'r') as f:
self.button_mapping = json.load(f)
self.update_button_status()
except:
pass
def load_settings(self):
"""Load application settings"""
try:
if os.path.exists(SETTINGS_FILE):
with open(SETTINGS_FILE, 'r') as f:
settings = json.load(f)
# Load auto-start setting
if 'auto_start' in settings:
self._auto_start = settings['auto_start']
self.autoStartChanged.emit(self._auto_start)
# Apply auto-start setting if needed
if self._auto_start:
self.apply_auto_start_setting()
# Load start minimized setting
if 'start_minimized' in settings:
self._start_minimized = settings['start_minimized']
self.startMinimizedChanged.emit(self._start_minimized)
# Load other settings
if 'invert_gas' in settings:
self._invert_gas = settings['invert_gas']
self.invertGasChanged.emit(self._invert_gas)
if 'invert_brake' in settings:
self._invert_brake = settings['invert_brake']
self.invertBrakeChanged.emit(self._invert_brake)
if 'square_input' in settings:
self._square_input = settings['square_input']
self.squareInputChanged.emit(self._square_input)
if 'square_area' in settings:
self._square_area = settings['square_area']
self.squareAreaChanged.emit(self._square_area)
if 'current_profile' in settings:
self._current_profile = settings['current_profile']
self.currentProfileChanged.emit(self._current_profile)
desc = PROFILES.get(self._current_profile, {}).get("desc", "Select a profile to see description")
self.profileDescChanged.emit(desc)
if 'current_device_index' in settings:
self._current_device_index = settings['current_device_index']
self.currentDeviceIndexChanged.emit(self._current_device_index)
# Auto-start emulation if auto-start is enabled and device exists
QTimer.singleShot(1000, self.try_auto_start_emulation)
except Exception as e:
print(f"Error loading settings: {e}")
@Slot()
def refresh_ui_from_settings(self):
"""Re-emit all signals to update QML UI with current backend state"""
self.currentProfileChanged.emit(self._current_profile)
desc = PROFILES.get(self._current_profile, {}).get("desc", "Select a profile to see description")
self.profileDescChanged.emit(desc)
self.currentDeviceIndexChanged.emit(self._current_device_index)
self.invertGasChanged.emit(self._invert_gas)
self.invertBrakeChanged.emit(self._invert_brake)
self.squareInputChanged.emit(self._square_input)
self.squareAreaChanged.emit(self._square_area)
self.autoStartChanged.emit(self._auto_start)
self.startMinimizedChanged.emit(self._start_minimized)
self.update_button_status() # Refresh button mapping status
# Also refresh tray manager settings to update UI
if self.tray_manager:
self.tray_manager.hideToTrayEnabledChanged.emit(self.tray_manager._hide_to_tray_enabled)
def try_auto_start_emulation(self):
"""Try to auto-start emulation with saved device"""
if not self._auto_start or not self._start_minimized:
return
try:
# Check if the saved device index is valid
if self._current_device_index < len(self._devices) and self._devices[self._current_device_index] != "No devices found":
# Select the device
self.select_device(self._current_device_index)
# Small delay to ensure device is ready
QTimer.singleShot(500, self.start_mapping)
print(f"Auto-starting emulation with device index {self._current_device_index}")
except Exception as e:
print(f"Failed to auto-start emulation: {e}")
def restore_device_selection(self):
"""Restore the saved device selection after startup"""
try:
# Check if the saved device index is valid
if 0 <= self._current_device_index < len(self._devices):
if self._devices[self._current_device_index] != "No devices found":
# Select the device without saving (to avoid triggering save during load)
self.select_device(self._current_device_index)
print(f"Restored device selection: {self._devices[self._current_device_index]}")
except Exception as e:
print(f"Failed to restore device selection: {e}")
def save_settings(self, force=False):
"""Save application settings"""
# Don't save during initialization to prevent overwriting loaded settings
if not force and hasattr(self, '_initializing') and self._initializing:
return
try:
settings = {}
if os.path.exists(SETTINGS_FILE):
with open(SETTINGS_FILE, 'r') as f:
settings = json.load(f)
settings.update({
'auto_start': self._auto_start,
'start_minimized': self._start_minimized,
'invert_gas': self._invert_gas,
'invert_brake': self._invert_brake,
'square_input': self._square_input,
'square_area': self._square_area,
'current_profile': self._current_profile,
'current_device_index': self._current_device_index,
})
with open(SETTINGS_FILE, 'w') as f:
json.dump(settings, f, indent=2)
except Exception as e:
print(f"Error saving settings: {e}")
@Slot()
def force_save_settings(self):
"""Force save settings, callable from QML"""
self.save_settings(force=True)
@Slot(bool)
def set_auto_start(self, enabled):
"""Set auto-start with Windows"""
self._auto_start = enabled
self.autoStartChanged.emit(enabled)
if enabled:
success = self.auto_start_manager.enable_auto_start()
if success:
self.statusChanged.emit("Auto-start enabled", "#22c55e")
else:
self.statusChanged.emit("Failed to enable auto-start", "#ef4444")
self._auto_start = False
self.autoStartChanged.emit(False)
else:
success = self.auto_start_manager.disable_auto_start()
if success:
self.statusChanged.emit("Auto-start disabled", "#71717a")
else:
self.statusChanged.emit("Failed to disable auto-start", "#ef4444")
self._auto_start = True
self.autoStartChanged.emit(True)
self.save_settings()
@Slot(bool)
def set_start_minimized(self, enabled):
"""Set start minimized to tray"""
self._start_minimized = enabled
self.startMinimizedChanged.emit(enabled)
self.save_settings()
def apply_auto_start_setting(self):
"""Apply the current auto-start setting"""
current_state = self.auto_start_manager.is_auto_start_enabled()
if current_state != self._auto_start:
if self._auto_start:
self.auto_start_manager.enable_auto_start()
else:
self.auto_start_manager.disable_auto_start()
def update_button_status(self):
count = len(self.button_mapping)
if count == 0:
status = "No buttons mapped"
elif count == 1:
status = "1 button mapped"
else:
status = f"{count} buttons mapped"
self.buttonStatusChanged.emit(status)
@Slot()
def refresh_devices(self):
# Stop emulation if running
if self._running:
self.stop_mapping()
# Clear current joystick from input reader
if self.input_reader:
self.input_reader.set_joystick(None)
try:
# Reinitialize pygame joystick system
pygame.joystick.quit()
pygame.joystick.init()
# Get available devices
count = pygame.joystick.get_count()
self._devices = []
for i in range(count):
try:
joystick = pygame.joystick.Joystick(i)
joystick.init() # Initialize to get name
device_name = joystick.get_name()
self._devices.append(f"{device_name} (ID: {i})")
joystick.quit() # Quit to avoid conflicts
except Exception as e:
print(f"Error initializing device {i}: {e}")
self._devices.append(f"Device {i} (Error)")
if not self._devices:
self._devices = ["No devices found"]
self.devicesChanged.emit(self._devices)
# Reset current joystick
self.joystick = None
self._axis_values = []
self._button_states = []
self.numAxesChanged.emit()
self.numButtonsChanged.emit()
self.axisValuesChanged.emit()
self.buttonStatesChanged.emit()
# Reset live preview values
self.steeringChanged.emit(0.5)
self.gasChanged.emit(0.0)
self.brakeChanged.emit(0.0)
if count > 0:
# Don't auto-select during initialization if we have a saved device selection
# The restore_device_selection() will handle selecting the saved device
if not (hasattr(self, '_initializing') and self._initializing):
# Only auto-select during normal refresh (not during startup)
self.select_device(0)
self.statusChanged.emit("Ready to activate", "#71717a")
else:
self.statusChanged.emit("No input device detected", "#f59e0b")
except Exception as e:
print(f"Error refreshing devices: {e}")
self._devices = ["Error scanning devices"]
self.devicesChanged.emit(self._devices)
self.statusChanged.emit("Error scanning devices", "#ef4444")
@Slot(int)
def select_device(self, index):
if self._running:
# Don't allow device change while emulating
self.statusChanged.emit("Stop emulation before changing device", "#f59e0b")
return
if 0 <= index < len(self._devices):
self._current_device_index = index
self.currentDeviceIndexChanged.emit(index)
try:
# Clean up old joystick if exists
if self.joystick:
try:
self.joystick.quit()
except:
pass
# Extract device ID from the string (format: "Device Name (ID: X)")
try:
device_id = int(self._devices[index].split("(ID: ")[1].rstrip(")"))
except:
device_id = index
# Initialize new joystick
self.joystick = pygame.joystick.Joystick(device_id)
self.joystick.init()
# Update input reader with new joystick
if self.input_reader:
self.input_reader.set_joystick(self.joystick)
# Initialize arrays with default values
num_axes = self.joystick.get_numaxes()
num_buttons = self.joystick.get_numbuttons()
self._axis_values = [0.5] * num_axes
self._button_states = [False] * num_buttons
# Emit signals for UI updates
self.numAxesChanged.emit()
self.numButtonsChanged.emit()
self.axisValuesChanged.emit()
self.buttonStatesChanged.emit()
print(f"Selected device: {self.joystick.get_name()} with {num_axes} axes and {num_buttons} buttons")
self.statusChanged.emit("Ready to activate", "#71717a")
# Save device selection (but not during initialization)
if not (hasattr(self, '_initializing') and self._initializing):
self.save_settings()
except Exception as e:
print(f"Error selecting device: {e}")
self.joystick = None
self._axis_values = []
self._button_states = []
self.numAxesChanged.emit()
self.numButtonsChanged.emit()
self.statusChanged.emit(f"Error selecting device: {str(e)}", "#ef4444")
@Slot(str)
def select_profile(self, profile_name):
if profile_name in PROFILES:
self._current_profile = profile_name
self.currentProfileChanged.emit(profile_name)
desc = PROFILES[profile_name]["desc"]
self.profileDescChanged.emit(desc)
self.save_settings()
@Slot(bool)
def set_invert_gas(self, value):
self._invert_gas = value
self.invertGasChanged.emit(value)
self.save_settings()
@Slot(bool)
def set_invert_brake(self, value):
self._invert_brake = value
self.invertBrakeChanged.emit(value)
self.save_settings()
@Slot(bool)
def set_square_input(self, value):
self._square_input = value
self.squareInputChanged.emit(value)
self.save_settings()
@Slot(float)
def set_square_area(self, value):
self._square_area = value
self.squareAreaChanged.emit(value)
self.save_settings()
@Slot()
def toggle_mapping(self):
if self._running:
self.stop_mapping()
else:
self.start_mapping()
def update_axis_values(self, values):
"""Update axis values from input reader thread"""
if values != self._axis_values:
self._axis_values = values
self.axisValuesChanged.emit()
def update_button_states(self, states):
"""Update button states from input reader thread"""
if states != self._button_states:
self._button_states = states
self.buttonStatesChanged.emit()
def process_inputs(self):
"""Process input values for display and emulation"""
if not self.joystick or not self._axis_values:
return
try:
# Always update live input preview, regardless of emulation state
if self._current_profile in PROFILES:
mapping = PROFILES[self._current_profile]["axes"]
def get_axis_value(idx):
if idx < len(self._axis_values):
return self._axis_values[idx]
return 0.5
# Steering with square input
if mapping[0] < len(self._axis_values):
raw_value = (self._axis_values[mapping[0]] * 2) - 1 # Convert to -1 to 1
if self._square_input:
# Apply square steering transformation
normalized = abs(raw_value)
square_threshold = self._square_area
if normalized <= square_threshold:
# Inside square area - linear response
scaled = normalized / square_threshold
else:
# Outside square area - jump to 100%
scaled = 1.0
# Apply direction
final_s = scaled if raw_value >= 0 else -scaled
steer = (final_s + 1) / 2
else:
# Normal circular steering
steer = self._axis_values[mapping[0]]
self.steeringChanged.emit(max(0.0, min(1.0, steer)))
# Gas with invert
if mapping[1] < len(self._axis_values):
gas = self._axis_values[mapping[1]]
if self._invert_gas:
gas = 1.0 - gas
self.gasChanged.emit(max(0.0, min(1.0, gas)))
# Brake with invert
if mapping[2] < len(self._axis_values):
brake = self._axis_values[mapping[2]]
if self._invert_brake:
brake = 1.0 - brake
self.brakeChanged.emit(max(0.0, min(1.0, brake)))
except Exception as e:
print(f"Error processing inputs: {e}")
@Slot()
def start_mapping(self):
if not self.joystick:
self.statusChanged.emit("No input device selected", "#ef4444")
return
self._running = True
self.isRunningChanged.emit(True)
self.statusChanged.emit("Active: Emulating controller", "#22c55e")
# Start mapping thread
self.mapper_thread = threading.Thread(target=self.mapping_loop, daemon=True)
self.mapper_thread.start()
@Slot()
def stop_mapping(self):
self._running = False
self.isRunningChanged.emit(False)
self.statusChanged.emit("Ready to activate", "#71717a")
def apply_square_steering(self, raw_input):
"""Apply square steering transformation based on configurable area"""
if not self._square_input:
return raw_input
normalized = abs(raw_input)
square_threshold = self._square_area
if normalized <= square_threshold:
# Inside square area - linear response
scaled = normalized / square_threshold
else:
# Outside square area - jump to 100%
scaled = 1.0
# Apply direction
return scaled if raw_input >= 0 else -scaled
def mapping_loop(self):
"""Main emulation loop - runs in separate thread"""
if not self.vg_gamepad:
self.vg_gamepad = vg.VX360Gamepad()
mapping = PROFILES[self._current_profile]["axes"]
while self._running:
try:
# Make local copies to avoid threading issues
button_states = self._button_states.copy() if self._button_states else []
axis_values = self._axis_values.copy() if self._axis_values else []
# STEERING with configurable square area
if mapping[0] < len(axis_values):
raw_s = (axis_values[mapping[0]] * 2) - 1 # Convert to -1 to 1
# Apply square steering transformation
final_s = self.apply_square_steering(raw_s)
# Clamp to valid range
final_s = max(-1.0, min(1.0, final_s))
self.vg_gamepad.left_joystick(x_value=int(final_s * 32767), y_value=0)
# GAS with invert
if mapping[1] < len(axis_values):
norm_g = axis_values[mapping[1]]
if self._invert_gas:
norm_g = 1.0 - norm_g
self.vg_gamepad.right_trigger(value=int(max(0.0, min(1.0, norm_g)) * 255))
# BRAKE with invert
if mapping[2] < len(axis_values):
norm_b = axis_values[mapping[2]]
if self._invert_brake:
norm_b = 1.0 - norm_b
self.vg_gamepad.left_trigger(value=int(max(0.0, min(1.0, norm_b)) * 255))
# BUTTONS - Handle both Xbox buttons and keyboard keys
for btn_idx_str, mapping_name in self.button_mapping.items():
btn_idx = int(btn_idx_str)
if btn_idx < len(button_states):
button_pressed = button_states[btn_idx]
# Check if it's an Xbox button
xbox_obj = XBOX_BUTTON_MAP.get(mapping_name)
if xbox_obj:
# Handle Xbox button
if button_pressed:
self.vg_gamepad.press_button(xbox_obj)
else:
self.vg_gamepad.release_button(xbox_obj)
# Check if it's a keyboard key
elif KEYBOARD_AVAILABLE and mapping_name in KEYBOARD_MAP:
key = KEYBOARD_MAP[mapping_name]
key_id = f"{btn_idx}_{mapping_name}"
if button_pressed and key_id not in self._pressed_keys:
# Button just pressed - press key
try:
self.keyboard.press(key)
self._pressed_keys.add(key_id)
except:
pass
elif not button_pressed and key_id in self._pressed_keys:
# Button just released - release key
try:
self.keyboard.release(key)
self._pressed_keys.remove(key_id)
except:
pass
# Update virtual gamepad
self.vg_gamepad.update()
time.sleep(0.001) # 1ms delay to prevent CPU overload
except Exception as e:
print(f"Error in mapping loop: {e}")
time.sleep(1)
# Clean up when stopping
if self.vg_gamepad:
# Release all buttons when stopping
for btn_name, btn_obj in XBOX_BUTTON_MAP.items():
if btn_obj:
self.vg_gamepad.release_button(btn_obj)
self.vg_gamepad.update()
# Release all keyboard keys when stopping
if KEYBOARD_AVAILABLE and self.keyboard:
for key_id in list(self._pressed_keys):
mapping_name = key_id.split('_', 1)[1] if '_' in key_id else ""
if mapping_name in KEYBOARD_MAP:
try:
self.keyboard.release(KEYBOARD_MAP[mapping_name])
except:
pass
self._pressed_keys.clear()
class MainWindowHandler(QObject):
"""Handles main window events to intercept minimize button"""
def __init__(self, main_window, tray_manager, start_minimized=False):
super().__init__()
self.main_window = main_window
self.tray_manager = tray_manager
self.start_minimized = start_minimized
def eventFilter(self, obj, event):
"""Intercept window state change events to handle minimize button"""
if obj is self.main_window and event.type() == QEvent.Type.WindowStateChange:
# Check if window was minimized
if self.main_window.windowState() == Qt.WindowState.WindowMinimized:
# Check if tray feature is enabled
if self.tray_manager.hideToTrayEnabled and self.tray_manager.trayAvailable:
# Hide window to tray
self.main_window.hide()
self.tray_manager.show_tray()
return True # Event handled
return False # Event not handled
if __name__ == "__main__":
# Check for command line arguments
start_minimized = "--minimized" in sys.argv
# Use QApplication instead of QGuiApplication to support QSystemTrayIcon
app = QApplication(sys.argv)
# Set application style and fonts
app.setStyle("Fusion")
# Don't quit when last window is closed (we'll manage this manually)
app.setQuitOnLastWindowClosed(False)
# Create and initialize backend
backend = TrueAxisBackend()
engine = QQmlApplicationEngine()
engine.rootContext().setContextProperty("backend", backend)
engine.rootContext().setContextProperty("trayManager", backend.tray_manager)
engine.loadData(QML_CODE.encode())
if not engine.rootObjects():
print("ERROR: Failed to load QML!")
sys.exit(-1)
# Get the main window and set it in the tray manager
main_window = engine.rootObjects()[0]
backend.tray_manager.main_window = main_window
# Refresh UI to sync with loaded settings (do this after QML is ready)
QTimer.singleShot(500, backend.refresh_ui_from_settings)
# Install event filter to intercept minimize button
window_handler = MainWindowHandler(main_window, backend.tray_manager, backend._start_minimized)
main_window.installEventFilter(window_handler)
# Set window icon
icon_pixmap = QPixmap(32, 32)
icon_pixmap.fill(Qt.GlobalColor.transparent)
painter = QPainter(icon_pixmap)
painter.setRenderHint(QPainter.Antialiasing)
painter.setBrush(QColor(16, 185, 129))
painter.setPen(Qt.PenStyle.NoPen)
painter.drawEllipse(4, 4, 24, 24)
painter.setPen(QColor(255, 255, 255))
painter.setFont(QFont("Inter", 16, QFont.Bold))
painter.drawText(icon_pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "T")
painter.end()
main_window.setIcon(QIcon(icon_pixmap))
# Handle start minimized
if backend._start_minimized or start_minimized:
# Check if tray is available
if backend.tray_manager.trayAvailable:
main_window.hide()
backend.tray_manager.show_tray()
backend.statusChanged.emit("Running in system tray", "#22c55e")
else:
# If tray not available, just minimize to taskbar
main_window.showMinimized()
# Save settings before app quits
app.aboutToQuit.connect(lambda: backend.save_settings(force=True))
app.aboutToQuit.connect(lambda: backend.tray_manager.save_settings())
sys.exit(app.exec())