diff --git a/.gitignore b/.gitignore index 72ad625..9918b99 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,9 @@ # file writer data **.h5 +# Qt Design Studio files +**.qtds + # ignore recover_config files recovery_config* diff --git a/bec_launcher/gui/__init__.py b/bec_launcher/gui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bec_launcher/gui/backend.py b/bec_launcher/gui/backend.py new file mode 100644 index 0000000..bddeda2 --- /dev/null +++ b/bec_launcher/gui/backend.py @@ -0,0 +1,305 @@ +""" +Backend for the BEC Launcher QML application. +Provides deployment data and launch actions to the QML frontend. +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path +from typing import List + +from PySide6.QtCore import Property, QObject, QSettings, Signal, Slot + +from bec_launcher.deployments import get_available_deployments, launch_deployment + +# Default to the deployments folder in bec_launcher package +DEFAULT_DEPLOYMENTS_PATH = str(Path(sys.prefix).parent.parent.parent / "config" / "bec") + +# Settings keys +SETTINGS_REMEMBER_CHOICE = "launcher/remember_choice" +SETTINGS_LAST_DEPLOYMENT = "launcher/last_deployment" +SETTINGS_LAST_ACTION = "launcher/last_action" # "terminal", "dock", or "app" + + +class Backend(QObject): + """Backend providing deployment data and actions for the QML UI.""" + + # Signals for property changes + deploymentNamesChanged = Signal() + deploymentPathsChanged = Signal() + selectedIndexChanged = Signal() + deploymentConfirmedChanged = Signal() + rememberChoiceChanged = Signal() + autoLaunchTriggered = Signal(str) # Emits "terminal", "dock", or "app" for auto-launch + quitApplication = Signal() # Signal to quit the application + + def __init__(self, base_path: str | None = None, fresh_start: bool = False): + super().__init__() + + self._base_path = base_path or DEFAULT_DEPLOYMENTS_PATH + self._fresh_start = fresh_start + self._settings = QSettings("PSI", "BECLauncher") + + print(f"[Backend] Using deployments path: {self._base_path}") + if fresh_start: + print("[Backend] Fresh start requested - ignoring saved preferences") + + self._deployment_names: List[str] = [] + self._deployment_paths_list: List[str] = [] + self._deployment_paths: dict[str, str] = {} + self._selected_index: int = -1 + self._deployment_confirmed: bool = False + self._remember_choice: bool = False + self._should_auto_launch: bool = False + self._auto_launch_action: str = "" + + # Load deployments + self._load_deployments() + + # Load saved preferences (unless fresh start) + if not fresh_start: + self._load_saved_preferences() + + self._auto_select_single_deployment() + + def _load_saved_preferences(self) -> None: + """Load saved preferences from QSettings.""" + self._remember_choice = self._settings.value(SETTINGS_REMEMBER_CHOICE, False, type=bool) + + if self._remember_choice: + saved_deployment = self._settings.value(SETTINGS_LAST_DEPLOYMENT, "", type=str) + saved_action = self._settings.value(SETTINGS_LAST_ACTION, "", type=str) + + if saved_deployment and saved_action: + if saved_action == "gui": + saved_action = "dock" + + # Find the index of the saved deployment + try: + idx = self._deployment_names.index(saved_deployment) + self._selected_index = idx + self._deployment_confirmed = True + self._should_auto_launch = True + self._auto_launch_action = saved_action + print(f"[Backend] Auto-selecting saved deployment: {saved_deployment}") + print(f"[Backend] Will auto-launch: {saved_action}") + except ValueError: + print(f"[Backend] Saved deployment '{saved_deployment}' not found, ignoring") + self._remember_choice = False + self._should_auto_launch = False + + def _save_preferences(self, action: str) -> None: + """Save current preferences to QSettings.""" + if self._remember_choice and self._selected_index >= 0: + deployment_name = self._deployment_names[self._selected_index] + self._settings.setValue(SETTINGS_REMEMBER_CHOICE, True) + self._settings.setValue(SETTINGS_LAST_DEPLOYMENT, deployment_name) + self._settings.setValue(SETTINGS_LAST_ACTION, action) + print(f"[Backend] Saved preferences: {deployment_name} -> {action}") + else: + self._settings.setValue(SETTINGS_REMEMBER_CHOICE, False) + self._settings.remove(SETTINGS_LAST_DEPLOYMENT) + self._settings.remove(SETTINGS_LAST_ACTION) + + def _auto_select_single_deployment(self, emit_signals: bool = False) -> None: + """Skip Step 1 when exactly one deployment is available.""" + if ( + self._should_auto_launch + or self._deployment_confirmed + or len(self._deployment_names) != 1 + ): + return + + should_emit_selected = self._selected_index != 0 + should_emit_confirmed = not self._deployment_confirmed + + self._selected_index = 0 + self._deployment_confirmed = True + print(f"[Backend] Auto-selecting the only deployment: {self._deployment_names[0]}") + + if emit_signals: + if should_emit_selected: + self.selectedIndexChanged.emit() + if should_emit_confirmed: + self.deploymentConfirmedChanged.emit() + + def _load_deployments(self) -> None: + """Load available deployments from the filesystem.""" + deployments = get_available_deployments(self._base_path) + + self._deployment_names = [] + self._deployment_paths_list = [] + self._deployment_paths = {} + + # Add production deployments first + for name in sorted(deployments["production"]): + self._deployment_names.append(name) + path = os.path.join(self._base_path, name) + self._deployment_paths_list.append(path) + self._deployment_paths[name] = path + + # Then add test deployments + for name in sorted(deployments["test"]): + self._deployment_names.append(name) + path = os.path.join(self._base_path, name) + self._deployment_paths_list.append(path) + self._deployment_paths[name] = path + + print( + f"[Backend] Found {len(self._deployment_names)} deployments: {self._deployment_names}" + ) + + self.deploymentNamesChanged.emit() + self.deploymentPathsChanged.emit() + + # ───────────────────────────────────────────────────────── + # Properties exposed to QML + # ───────────────────────────────────────────────────────── + + @Property(list, notify=deploymentNamesChanged) + def deploymentNames(self) -> List[str]: + """List of deployment names available for selection.""" + return self._deployment_names + + @Property(list, notify=deploymentPathsChanged) + def deploymentPaths(self) -> List[str]: + """List of deployment paths corresponding to deploymentNames.""" + return self._deployment_paths_list + + @Property(int, notify=selectedIndexChanged) + def selectedIndex(self) -> int: + """Currently selected deployment index.""" + return self._selected_index + + @Property(bool, notify=deploymentConfirmedChanged) + def deploymentConfirmed(self) -> bool: + """Whether a deployment has been confirmed.""" + return self._deployment_confirmed + + @Property(bool, notify=rememberChoiceChanged) + def rememberChoice(self) -> bool: + """Whether to remember the user's last choice.""" + return self._remember_choice + + @Property(bool, constant=True) + def shouldAutoLaunch(self) -> bool: + """Whether to auto-launch without showing UI.""" + return self._should_auto_launch + + @Property(str, constant=True) + def autoLaunchAction(self) -> str: + """The action to auto-launch ('terminal', 'dock', or 'app').""" + return self._auto_launch_action + + # ───────────────────────────────────────────────────────── + # Slots callable from QML + # ───────────────────────────────────────────────────────── + + @Slot(int) + def selectDeployment(self, index: int) -> None: + """Select a deployment by index.""" + if index < 0 or index >= len(self._deployment_names): + return + + if self._selected_index != index: + self._selected_index = index + self.selectedIndexChanged.emit() + + @Slot() + def confirmDeployment(self) -> None: + """Confirm the current deployment selection.""" + if self._selected_index < 0: + return + + self._deployment_confirmed = True + self.deploymentConfirmedChanged.emit() + + if self._remember_choice: + self._save_preferences("terminal") + + @Slot() + def changeDeployment(self) -> None: + """Go back to deployment selection.""" + self._deployment_confirmed = False + self.deploymentConfirmedChanged.emit() + + @Slot(bool) + def setRememberChoice(self, remember: bool) -> None: + """Set whether to remember the user's choice.""" + if self._remember_choice != remember: + self._remember_choice = remember + self.rememberChoiceChanged.emit() + + if not remember: + # Clear saved preferences when unchecked + self._settings.setValue(SETTINGS_REMEMBER_CHOICE, False) + self._settings.remove(SETTINGS_LAST_DEPLOYMENT) + self._settings.remove(SETTINGS_LAST_ACTION) + + @Slot() + def resetPreferences(self) -> None: + """Reset all saved preferences.""" + self._remember_choice = False + self._settings.setValue(SETTINGS_REMEMBER_CHOICE, False) + self._settings.remove(SETTINGS_LAST_DEPLOYMENT) + self._settings.remove(SETTINGS_LAST_ACTION) + self.rememberChoiceChanged.emit() + print("[Backend] Preferences reset") + + @Slot() + def launchTerminal(self) -> None: + """Launch a terminal with the selected deployment's environment.""" + self._launch_action("terminal", "bec --nogui ", "terminal") + + @Slot() + def launchDock(self) -> None: + """Launch BEC with the dock companion workflow.""" + self._launch_action("dock", "bec ", "dock companion") + + @Slot() + def launchApp(self) -> None: + """Launch the standalone BEC App.""" + self._launch_action("app", "bec-app", "BEC App") + + @Slot() + def launchGui(self) -> None: + """Backward-compatible alias for the dock companion action.""" + self.launchDock() + + def _launch_action(self, action: str, command: str, label: str) -> None: + """Launch the selected deployment using the requested command.""" + if self._selected_index < 0 or self._selected_index >= len(self._deployment_names): + print("[Backend] No deployment selected") + return + + name = self._deployment_names[self._selected_index] + path = self._deployment_paths.get(name) + + if not path: + print(f"[Backend] Path not found for deployment: {name}") + return + + if self._remember_choice: + self._save_preferences(action) + + print(f"[Backend] Launching {label} for deployment: {name} at {path}") + + try: + launch_deployment(path, command, activate_env=True) + self.quitApplication.emit() + except Exception as e: + print(f"[Backend] Error launching {label}: {e}") + + @Slot() + def refresh(self) -> None: + """Refresh the list of deployments.""" + self._load_deployments() + self._selected_index = -1 + self._deployment_confirmed = False + self._should_auto_launch = False + self._auto_launch_action = "" + self._auto_select_single_deployment() + self.selectedIndexChanged.emit() + self.deploymentConfirmedChanged.emit() diff --git a/bec_launcher/gui/main.py b/bec_launcher/gui/main.py new file mode 100644 index 0000000..4fcb416 --- /dev/null +++ b/bec_launcher/gui/main.py @@ -0,0 +1,96 @@ +import argparse +import os +import sys + +from PySide6.QtCore import QTimer, QUrl +from PySide6.QtGui import QGuiApplication +from PySide6.QtQml import QQmlApplicationEngine + +from bec_launcher.gui.backend import Backend + +# Set Qt Quick Controls style to Basic (supports customization) +os.environ["QT_QUICK_CONTROLS_STYLE"] = "Basic" + + +def parse_args() -> argparse.Namespace: + """Parse command-line arguments.""" + parser = argparse.ArgumentParser( + description="BEC Launcher - Launch BEC deployments", prog="bec-launcher" + ) + parser.add_argument( + "--fresh", + "--reset", + action="store_true", + dest="fresh_start", + help="Start fresh, ignoring saved preferences (deployment and action)", + ) + parser.add_argument( + "--base-path", + type=str, + default=None, + help="Base path for deployments, typically /sls//config/bec", + ) + return parser.parse_args() + + +def main() -> int: + # Parse arguments before creating QGuiApplication + args = parse_args() + + # Useful diagnostics during development (uncomment if needed) + # os.environ["QML_IMPORT_TRACE"] = "1" + # os.environ["QML_DISABLE_DISK_CACHE"] = "1" + + app = QGuiApplication(sys.argv) + + # Backend injection with fresh_start option + backend = Backend(base_path=args.base_path, fresh_start=args.fresh_start) + + # Connect quitApplication signal to app.quit + backend.quitApplication.connect(app.quit) + + # Check if we should auto-launch without showing UI + if backend.shouldAutoLaunch: + print(f"[Main] Auto-launching {backend.autoLaunchAction} without UI") + + # Use QTimer to launch after event loop starts + def do_auto_launch(): + if backend.autoLaunchAction == "terminal": + backend.launchTerminal() + elif backend.autoLaunchAction in {"dock", "gui"}: + backend.launchDock() + elif backend.autoLaunchAction == "app": + backend.launchApp() + + QTimer.singleShot(0, do_auto_launch) + return app.exec() + + # Normal flow - show UI + engine = QQmlApplicationEngine() + + base_dir = os.path.dirname(os.path.abspath(__file__)) + + # This directory must be the parent directory that contains the QML module folder "Launcher" + qml_root = os.path.join(base_dir, "qml") + engine.addImportPath(qml_root) + + # Add the Launcher project folder so that "import Launcher" finds qml/Launcher/Launcher/qmldir + qml_launcher_dir = os.path.join(qml_root, "Launcher") + engine.addImportPath(qml_launcher_dir) + + qml_content_dir = os.path.join(qml_root, "Launcher", "LauncherContent") + engine.addImportPath(qml_content_dir) + + engine.rootContext().setContextProperty("backend", backend) + + app_qml = os.path.join(qml_root, "Launcher", "LauncherContent", "App.qml") + engine.load(QUrl.fromLocalFile(app_qml)) + + if not engine.rootObjects(): + return 1 + + return app.exec() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bec_launcher/gui/qml/Launcher/Generated/Generated.txt b/bec_launcher/gui/qml/Launcher/Generated/Generated.txt new file mode 100644 index 0000000..84c843f --- /dev/null +++ b/bec_launcher/gui/qml/Launcher/Generated/Generated.txt @@ -0,0 +1 @@ +Imported 3D assets and components imported from bundles will be created in this folder. diff --git a/bec_launcher/gui/qml/Launcher/Launcher.qmlproject b/bec_launcher/gui/qml/Launcher/Launcher.qmlproject new file mode 100644 index 0000000..8935189 --- /dev/null +++ b/bec_launcher/gui/qml/Launcher/Launcher.qmlproject @@ -0,0 +1,122 @@ +import QmlProject + +Project { + mainFile: "LauncherContent/App.qml" + mainUiFile: "LauncherContent/AppForm.ui.qml" + + /* Include .qml, .js, and image files from current directory and subdirectories */ + QmlFiles { + directory: "Launcher" + } + + QmlFiles { + directory: "LauncherContent" + } + + QmlFiles { + directory: "Generated" + } + + JavaScriptFiles { + directory: "Launcher" + } + + JavaScriptFiles { + directory: "LauncherContent" + } + + ImageFiles { + directory: "LauncherContent" + } + + ImageFiles { + directory: "Generated" + } + + Files { + filter: "*.conf" + files: ["qtquickcontrols2.conf"] + } + + Files { + filter: "qmldir" + directory: "." + } + + FontFiles { + filter: "*.ttf;*.otf" + } + + Files { + filter: "*.wav;*.mp3" + } + + Files { + filter: "*.mp4" + } + + Files { + filter: "*.glsl;*.glslv;*.glslf;*.vsh;*.fsh;*.vert;*.frag" + } + + Files { + filter: "*.qsb" + } + + Files { + filter: "*.json" + } + + Files { + filter: "*.mesh" + directory: "Generated" + } + + Files { + filter: "*.qad" + directory: "Generated" + } + + Environment { + QT_QUICK_CONTROLS_CONF: "qtquickcontrols2.conf" + QML_COMPAT_RESOLVE_URLS_ON_ASSIGNMENT: "1" + QT_LOGGING_RULES: "qt.qml.connections=false" + QT_ENABLE_HIGHDPI_SCALING: "0" + /* Useful for debugging + QSG_VISUALIZE=batches + QSG_VISUALIZE=clip + QSG_VISUALIZE=changes + QSG_VISUALIZE=overdraw + */ + } + + qt6Project: true + + /* List of plugin directories passed to QML runtime */ + importPaths: [ "." ] + + /* Required for deployment */ + targetDirectory: "/opt/Launcher" + + + qdsVersion: "4.8" + + quickVersion: "6.8" + + /* If any modules the project imports require widgets (e.g. QtCharts), widgetApp must be true */ + widgetApp: true + + /* args: Specifies command line arguments for qsb tool to generate shaders. + files: Specifies target files for qsb tool. If path is included, it must be relative to this file. + Wildcard '*' can be used in the file name part of the path. + e.g. files: [ "LauncherContent/shaders/*.vert", "*.frag" ] */ + ShaderTool { + args: "-s --glsl \"100 es,120,150\" --hlsl 50 --msl 12" + files: [ "LauncherContent/shaders/*" ] + } + + multilanguageSupport: true + supportedLanguages: ["en"] + primaryLanguage: "en" + +} diff --git a/bec_launcher/gui/qml/Launcher/Launcher.qrc b/bec_launcher/gui/qml/Launcher/Launcher.qrc new file mode 100644 index 0000000..76a25c1 --- /dev/null +++ b/bec_launcher/gui/qml/Launcher/Launcher.qrc @@ -0,0 +1,20 @@ + + + Launcher.qmlproject + Launcher/Constants.qml + Launcher/EventListModel.qml + Launcher/EventListSimulator.qml + Launcher/qmldir + Launcher/Theme.qml + LauncherContent/App.qml + LauncherContent/AppForm.ui.qml + LauncherContent/images/BEC_app.png + LauncherContent/images/BEC_comp.png + LauncherContent/images/BEC_terminal.png + LauncherContent/qmldir + LauncherContent/ui/ActionCard.ui.qml + LauncherContent/ui/DeploymentCard.ui.qml + LauncherContent/ui/qmldir + qtquickcontrols2.conf + + diff --git a/bec_launcher/gui/qml/Launcher/Launcher/Constants.qml b/bec_launcher/gui/qml/Launcher/Launcher/Constants.qml new file mode 100644 index 0000000..0bb2ab3 --- /dev/null +++ b/bec_launcher/gui/qml/Launcher/Launcher/Constants.qml @@ -0,0 +1,35 @@ +pragma Singleton +import QtQuick + +QtObject { + readonly property int windowWidth: 800 + readonly property int windowMinHeight: 320 + + readonly property int pageMargin: 16 + readonly property int sectionPadding: 12 + readonly property int sectionGap: 12 + readonly property int sectionRadius: 14 + readonly property int headerHeight: 30 + readonly property int sectionHeaderHeight: 25 + readonly property int stepLabelHeight: 20 + readonly property int dividerThickness: 1 + + readonly property int deploymentListVisibleCount: 4 + readonly property int deploymentCardPadding: 12 + readonly property int deploymentCardGap: 8 + + readonly property int changeButtonWidth: 80 + readonly property int smallButtonHeight: 32 + readonly property int primaryButtonHeight: 40 + + readonly property int checkboxRowHeight: 28 + readonly property int checkboxIndicatorSize: 18 + + readonly property int actionCardHeight: 170 + readonly property int actionCardGap: 12 + readonly property int actionCardPadding: 12 + readonly property int actionCardButtonGap: 6 + readonly property int actionCardIconSize: 64 + readonly property int actionCardTitleHeight: 20 + readonly property int actionCardButtonHeight: 40 +} diff --git a/bec_launcher/gui/qml/Launcher/Launcher/EventListModel.qml b/bec_launcher/gui/qml/Launcher/Launcher/EventListModel.qml new file mode 100644 index 0000000..00c7065 --- /dev/null +++ b/bec_launcher/gui/qml/Launcher/Launcher/EventListModel.qml @@ -0,0 +1,12 @@ +import QtQuick + +ListModel { + id: eventListModel + + ListElement { + eventId: "enterPressed" + eventDescription: "Emitted when pressing the enter button" + shortcut: "Return" + parameters: "Enter" + } +} diff --git a/bec_launcher/gui/qml/Launcher/Launcher/EventListSimulator.qml b/bec_launcher/gui/qml/Launcher/Launcher/EventListSimulator.qml new file mode 100644 index 0000000..d26ae6d --- /dev/null +++ b/bec_launcher/gui/qml/Launcher/Launcher/EventListSimulator.qml @@ -0,0 +1,22 @@ +import QtQuick +import QtQuick.Studio.EventSimulator +import QtQuick.Studio.EventSystem + +QtObject { + id: simulator + property bool active: true + + property Timer __timer: Timer { + id: timer + interval: 100 + onTriggered: { + EventSimulator.show() + } + } + + Component.onCompleted: { + EventSystem.init(Qt.resolvedUrl("EventListModel.qml")) + if (simulator.active) + timer.start() + } +} diff --git a/bec_launcher/gui/qml/Launcher/Launcher/Theme.qml b/bec_launcher/gui/qml/Launcher/Launcher/Theme.qml new file mode 100644 index 0000000..c48ffe7 --- /dev/null +++ b/bec_launcher/gui/qml/Launcher/Launcher/Theme.qml @@ -0,0 +1,38 @@ +pragma Singleton +import QtQuick + +QtObject { + readonly property color background: "#0e1017" + readonly property color backgroundCard: "#161921" + readonly property color backgroundCardHover: "#1c2029" + readonly property color backgroundCardSelected: "#1f2533" + readonly property color backgroundInput: "#0d1015" + + readonly property color border: "#2b3342" + readonly property color borderHover: "#3a4556" + readonly property color borderSelected: "#3daee9" + + readonly property color textPrimary: "#ffffff" + readonly property color textSecondary: "#b8c0cc" + readonly property color textMuted: "#6b7280" + readonly property color textDisabled: "#4b5563" + + readonly property color accent: "#3daee9" + readonly property color accentHover: "#52b8eb" + readonly property color accentPressed: "#2a9cd8" + + readonly property color badgeProd: "#22c55e" + readonly property color badgeProdBg: Qt.rgba(34/255, 197/255, 94/255, 0.15) + readonly property color badgeTest: "#f59e0b" + readonly property color badgeTestBg: Qt.rgba(245/255, 158/255, 11/255, 0.15) + readonly property color badgeDev: "#3b82f6" + readonly property color badgeDevBg: Qt.rgba(59/255, 130/255, 246/255, 0.15) + + readonly property color buttonSecondary: "#1f2937" + readonly property color buttonSecondaryHover: "#374151" + + readonly property color divider: "#2b3342" + + readonly property int radiusSmall: 6 + readonly property int radiusMedium: 10 +} diff --git a/bec_launcher/gui/qml/Launcher/Launcher/designer/plugin.metainfo b/bec_launcher/gui/qml/Launcher/Launcher/designer/plugin.metainfo new file mode 100644 index 0000000..cad10e0 --- /dev/null +++ b/bec_launcher/gui/qml/Launcher/Launcher/designer/plugin.metainfo @@ -0,0 +1,13 @@ +MetaInfo { + Type { + name: "Launcher.EventListSimulator" + icon: ":/qtquickplugin/images/item-icon16.png" + + Hints { + visibleInNavigator: true + canBeDroppedInNavigator: true + canBeDroppedInFormEditor: false + canBeDroppedInView3D: false + } + } +} diff --git a/bec_launcher/gui/qml/Launcher/Launcher/qmldir b/bec_launcher/gui/qml/Launcher/Launcher/qmldir new file mode 100644 index 0000000..5d4a3e4 --- /dev/null +++ b/bec_launcher/gui/qml/Launcher/Launcher/qmldir @@ -0,0 +1,3 @@ +module Launcher +singleton Constants 1.0 Constants.qml +singleton Theme 1.0 Theme.qml diff --git a/bec_launcher/gui/qml/Launcher/LauncherContent/App.qml b/bec_launcher/gui/qml/Launcher/LauncherContent/App.qml new file mode 100644 index 0000000..09fec3d --- /dev/null +++ b/bec_launcher/gui/qml/Launcher/LauncherContent/App.qml @@ -0,0 +1,124 @@ +import QtQuick +import QtQuick.Window +import Launcher + +Window { + id: window + readonly property int targetHeight: Math.max( + Constants.windowMinHeight, + Math.ceil(appForm.implicitHeight) + ) + + width: Constants.windowWidth + height: targetHeight + minimumWidth: Constants.windowWidth + minimumHeight: targetHeight + maximumWidth: Constants.windowWidth + maximumHeight: targetHeight + visible: true + title: "BEC Launcher" + color: Theme.background + + AppForm { + id: appForm + anchors.fill: parent + deploymentNames: backend.deploymentNames + deploymentPaths: backend.deploymentPaths + selectedIndex: backend.selectedIndex + deploymentConfirmed: backend.deploymentConfirmed + rememberChoice: backend.rememberChoice + onDeploymentSelected: (index) => backend.selectDeployment(index) + onConfirmDeployment: backend.confirmDeployment() + onChangeDeployment: backend.changeDeployment() + onLaunchTerminal: backend.launchTerminal() + onLaunchDock: backend.launchDock() + onLaunchApp: backend.launchApp() + onRememberChoiceToggled: (checked) => backend.setRememberChoice(checked) + } + + Connections { + target: appForm.changeButton + function onClicked() { appForm.changeDeployment() } + } + + Connections { + target: appForm.confirmButton + function onClicked() { appForm.confirmDeployment() } + } + + Connections { + target: appForm.terminalCard.launchButton + function onClicked() { appForm.launchTerminal() } + } + + Connections { + target: appForm.terminalCard.cardMouseArea + function onEntered() { appForm.terminalCard.isHovered = true } + function onExited() { appForm.terminalCard.isHovered = false } + } + + Connections { + target: appForm.dockCard.launchButton + function onClicked() { appForm.launchDock() } + } + + Connections { + target: appForm.dockCard.cardMouseArea + function onEntered() { appForm.dockCard.isHovered = true } + function onExited() { appForm.dockCard.isHovered = false } + } + + Connections { + target: appForm.appCard.launchButton + function onClicked() { appForm.launchApp() } + } + + Connections { + target: appForm.appCard.cardMouseArea + function onEntered() { appForm.appCard.isHovered = true } + function onExited() { appForm.appCard.isHovered = false } + } + + Connections { + target: appForm.deploymentRepeater + + function onItemAdded(index, item) { + item.cardMouseArea.clicked.connect(function() { + appForm.deploymentSelected(index) + }) + item.cardMouseArea.entered.connect(function() { + item.isHovered = true + }) + item.cardMouseArea.exited.connect(function() { + item.isHovered = false + }) + } + } + + Connections { + target: appForm.rememberCheckbox + function onCheckedChanged() { + appForm.rememberChoiceToggled(appForm.rememberCheckbox.checked) + } + } + + Connections { + target: backend + + function onAutoLaunchTriggered(action) { + console.log("Auto-launching:", action) + if (action === "terminal") { + appForm.launchTerminal() + } else if (action === "dock" || action === "gui") { + appForm.launchDock() + } else if (action === "app") { + appForm.launchApp() + } + } + + function onQuitApplication() { + console.log("Quitting application") + Qt.quit() + } + } +} diff --git a/bec_launcher/gui/qml/Launcher/LauncherContent/AppForm.ui.qml b/bec_launcher/gui/qml/Launcher/LauncherContent/AppForm.ui.qml new file mode 100644 index 0000000..926ea90 --- /dev/null +++ b/bec_launcher/gui/qml/Launcher/LauncherContent/AppForm.ui.qml @@ -0,0 +1,448 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import Launcher +import "ui" + +Rectangle { + id: root + width: Constants.windowWidth + color: Theme.background + implicitWidth: Constants.windowWidth + implicitHeight: root.pageContentHeight + + property var deploymentNames: [] + property var deploymentPaths: [] + property int selectedIndex: -1 + property bool deploymentConfirmed: false + property bool rememberChoice: false + + signal deploymentSelected(int index) + signal confirmDeployment() + signal changeDeployment() + signal launchTerminal() + signal launchDock() + signal launchApp() + signal rememberChoiceToggled(bool checked) + + state: root.deploymentConfirmed ? "selectAction" : "selectDeployment" + + readonly property real collapsedHeight: (Constants.sectionPadding * 2) + Constants.sectionHeaderHeight + readonly property real actionCardsHeight: Constants.stepLabelHeight + + Constants.sectionGap + + Constants.actionCardHeight + + Constants.sectionGap + + Constants.checkboxRowHeight + readonly property real deploymentCardHeight: deploymentRepeater.count > 0 && deploymentRepeater.itemAt(0) + ? deploymentRepeater.itemAt(0).height + : 0 + readonly property real deploymentListContentHeight: deploymentListColumn.implicitHeight + readonly property real deploymentViewportCapHeight: root.deploymentCardHeight > 0 + ? (Constants.deploymentListVisibleCount * root.deploymentCardHeight) + + (Math.max(Constants.deploymentListVisibleCount - 1, 0) * Constants.deploymentCardGap) + : 0 + readonly property real actionSectionVisibleHeight: root.deploymentConfirmed ? root.actionCardsHeight : 0 + readonly property real actionSectionGap: root.deploymentConfirmed ? Constants.sectionGap : 0 + readonly property real deploymentViewportHeight: root.deploymentConfirmed + ? 0 + : Math.min(root.deploymentListContentHeight, root.deploymentViewportCapHeight) + readonly property real deploymentSectionHeight: root.deploymentConfirmed + ? root.collapsedHeight + : root.collapsedHeight + + (Constants.sectionGap * 3) + + Constants.dividerThickness + + root.deploymentViewportHeight + + Constants.primaryButtonHeight + readonly property real pageContentHeight: (Constants.pageMargin * 2) + + Constants.headerHeight + + Constants.dividerThickness + + (Constants.sectionGap * 2) + + root.deploymentSectionHeight + + root.actionSectionGap + + root.actionSectionVisibleHeight + + property alias changeButton: changeButton + property alias confirmButton: confirmButton + property alias terminalCard: terminalCard + property alias dockCard: dockCard + property alias appCard: appCard + property alias deploymentRepeater: deploymentRepeater + property alias rememberCheckbox: rememberCheckbox + + states: [ + State { + name: "selectDeployment" + PropertyChanges { deploymentSection.height: root.deploymentSectionHeight } + PropertyChanges { deploymentListDivider.opacity: 1.0 } + PropertyChanges { + deploymentListWrapper.height: root.deploymentViewportHeight + deploymentListWrapper.opacity: 1.0 + } + PropertyChanges { confirmButton.opacity: 1.0 } + PropertyChanges { + actionSection.height: 0 + actionSection.opacity: 0.0 + } + }, + State { + name: "selectAction" + PropertyChanges { deploymentSection.height: root.collapsedHeight } + PropertyChanges { deploymentListDivider.opacity: 0.0 } + PropertyChanges { + deploymentListWrapper.height: 0 + deploymentListWrapper.opacity: 0.0 + } + PropertyChanges { confirmButton.opacity: 0.0 } + PropertyChanges { + actionSection.height: root.actionCardsHeight + actionSection.opacity: 1.0 + } + } + ] + + transitions: [ + Transition { + from: "selectDeployment" + to: "selectAction" + SequentialAnimation { + NumberAnimation { + targets: [deploymentSection, deploymentListDivider, deploymentListWrapper, confirmButton] + properties: "height,opacity" + duration: 220 + easing.type: Easing.InCubic + } + NumberAnimation { + target: actionSection + properties: "height,opacity" + duration: 240 + easing.type: Easing.OutCubic + } + } + }, + Transition { + from: "selectAction" + to: "selectDeployment" + SequentialAnimation { + NumberAnimation { + target: actionSection + properties: "height,opacity" + duration: 180 + easing.type: Easing.InCubic + } + NumberAnimation { + targets: [deploymentSection, deploymentListDivider, deploymentListWrapper, confirmButton] + properties: "height,opacity" + duration: 240 + easing.type: Easing.OutCubic + } + } + } + ] + + Column { + id: pageColumn + anchors.fill: parent + anchors.margins: Constants.pageMargin + spacing: Constants.sectionGap + + Rectangle { + id: headerRow + width: parent.width + height: Constants.headerHeight + color: "transparent" + + Text { + text: "BEC Launcher" + color: Theme.textPrimary + font.pixelSize: 22 + font.weight: Font.Bold + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + } + } + + Rectangle { + width: parent.width + height: Constants.dividerThickness + color: Theme.divider + } + + Rectangle { + id: deploymentSection + width: parent.width + height: root.deploymentSectionHeight + radius: Constants.sectionRadius + color: Theme.backgroundCard + border.width: 1 + border.color: Theme.border + clip: true + + Column { + id: deploymentContent + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Constants.sectionPadding + spacing: Constants.sectionGap + + Rectangle { + id: deploymentHeader + width: parent.width + height: Constants.sectionHeaderHeight + color: "transparent" + + Column { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + anchors.right: headerButtons.left + anchors.rightMargin: 10 + spacing: 2 + + Text { + text: root.deploymentConfirmed ? "Selected Deployment" : "Step 1: Select Deployment" + color: Theme.textSecondary + font.pixelSize: 11 + font.weight: Font.Medium + } + + Text { + text: root.selectedIndex >= 0 && root.selectedIndex < root.deploymentNames.length + ? root.deploymentNames[root.selectedIndex] + : "Choose a deployment..." + color: Theme.textPrimary + font.pixelSize: 15 + font.weight: Font.DemiBold + } + } + + Row { + id: headerButtons + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + + Button { + id: changeButton + visible: root.deploymentConfirmed + enabled: root.deploymentNames.length > 1 + width: Constants.changeButtonWidth + height: Constants.smallButtonHeight + text: "Change" + + ToolTip.visible: !changeButton.enabled && changeButton.hovered + ToolTip.text: "There is only one deployment available." + + contentItem: Text { + text: changeButton.text + color: changeButton.enabled ? Theme.textPrimary : Theme.textDisabled + font.pixelSize: 12 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + radius: 6 + color: !changeButton.enabled ? Theme.buttonSecondary + : changeButton.pressed ? Theme.buttonSecondaryHover + : changeButton.hovered ? Theme.buttonSecondaryHover + : Theme.buttonSecondary + opacity: changeButton.enabled ? 1.0 : 0.6 + border.width: 1 + border.color: Theme.border + } + } + + Text { + text: root.deploymentConfirmed ? "▸" : "▾" + color: Theme.textMuted + font.pixelSize: 14 + anchors.verticalCenter: parent.verticalCenter + } + } + } + + Rectangle { + id: deploymentListDivider + width: parent.width + height: Constants.dividerThickness + color: Theme.divider + opacity: 1.0 + visible: opacity > 0 + } + + ScrollView { + id: deploymentListWrapper + width: parent.width + height: root.deploymentViewportHeight + clip: true + opacity: 1.0 + visible: height > 0 + contentWidth: availableWidth + + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + ScrollBar.vertical.policy: root.deploymentListContentHeight > root.deploymentViewportHeight + ? ScrollBar.AsNeeded + : ScrollBar.AlwaysOff + + Column { + id: deploymentListColumn + width: deploymentListWrapper.availableWidth + spacing: Constants.deploymentCardGap + + Repeater { + id: deploymentRepeater + model: root.deploymentNames + + delegate: DeploymentCard { + required property int index + required property string modelData + + width: deploymentListColumn.width + deploymentName: modelData + deploymentPath: index < root.deploymentPaths.length ? root.deploymentPaths[index] : "" + badgeType: modelData.toLowerCase().indexOf("test") >= 0 ? "test" + : modelData.toLowerCase().indexOf("dev") >= 0 ? "dev" + : "prod" + isSelected: index === root.selectedIndex + } + } + } + } + + Button { + id: confirmButton + width: parent.width + height: Constants.primaryButtonHeight + text: "Confirm Selection" + enabled: root.selectedIndex >= 0 + opacity: 1.0 + visible: opacity > 0 + + contentItem: Text { + text: confirmButton.text + color: confirmButton.enabled ? Theme.textPrimary : Theme.textDisabled + font.pixelSize: 14 + font.weight: Font.DemiBold + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + radius: 6 + color: confirmButton.enabled + ? (confirmButton.pressed ? Theme.accentPressed + : confirmButton.hovered ? Theme.accentHover + : Theme.accent) + : Theme.buttonSecondary + opacity: confirmButton.enabled ? 1.0 : 0.5 + } + } + } + } + + Rectangle { + id: actionSection + width: parent.width + height: 0 + color: "transparent" + opacity: 0.0 + visible: height > 0 + clip: true + + Column { + anchors.fill: parent + spacing: Constants.sectionGap + + Text { + text: "Step 2: Choose App Environment" + height: Constants.stepLabelHeight + color: Theme.textSecondary + font.pixelSize: 11 + font.weight: Font.Medium + } + + Row { + width: parent.width + height: Constants.actionCardHeight + spacing: Constants.actionCardGap + + ActionCard { + id: terminalCard + width: (parent.width - (Constants.actionCardGap * 2)) / 3 + height: Constants.actionCardHeight + title: "Terminal" + description: "Open BEC in terminal without a graphical user interface." + icon: ">" + iconSource: Qt.resolvedUrl("images/BEC_terminal.png") + buttonText: "Open Terminal" + } + + ActionCard { + id: dockCard + width: (parent.width - (Constants.actionCardGap * 2)) / 3 + height: Constants.actionCardHeight + title: "Terminal + Dock" + description: "Open BEC in terminal with the GUI dock area companion window." + icon: "#" + iconSource: Qt.resolvedUrl("images/BEC_comp.png") + buttonText: "Open Terminal + Dock" + } + + ActionCard { + id: appCard + width: (parent.width - (Constants.actionCardGap * 2)) / 3 + height: Constants.actionCardHeight + title: "BEC App" + description: "Fully fledged BEC desktop application environment." + icon: "[]" + iconSource: Qt.resolvedUrl("images/BEC_app.png") + buttonText: "Launch BEC App" + } + } + + Rectangle { + width: parent.width + height: Constants.checkboxRowHeight + color: "transparent" + + CheckBox { + id: rememberCheckbox + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + checked: root.rememberChoice + text: "Remember my choice (skip this screen next time)" + + contentItem: Text { + text: rememberCheckbox.text + color: Theme.textSecondary + font.pixelSize: 12 + leftPadding: rememberCheckbox.indicator.width + rememberCheckbox.spacing + verticalAlignment: Text.AlignVCenter + } + + indicator: Rectangle { + implicitWidth: Constants.checkboxIndicatorSize + implicitHeight: Constants.checkboxIndicatorSize + x: rememberCheckbox.leftPadding + y: (parent.height - height) / 2 + radius: 4 + color: rememberCheckbox.checked ? Theme.accent : Theme.backgroundInput + border.color: rememberCheckbox.checked ? Theme.accent : Theme.border + border.width: 1 + + Text { + anchors.centerIn: parent + text: "✓" + color: Theme.textPrimary + font.pixelSize: 12 + font.weight: Font.Bold + visible: rememberCheckbox.checked + } + } + } + } + } + } + } +} diff --git a/bec_launcher/gui/qml/Launcher/LauncherContent/fonts/fonts.txt b/bec_launcher/gui/qml/Launcher/LauncherContent/fonts/fonts.txt new file mode 100644 index 0000000..ab96122 --- /dev/null +++ b/bec_launcher/gui/qml/Launcher/LauncherContent/fonts/fonts.txt @@ -0,0 +1 @@ +Fonts in this folder are loaded automatically. diff --git a/bec_launcher/gui/qml/Launcher/LauncherContent/images/BEC_app.png b/bec_launcher/gui/qml/Launcher/LauncherContent/images/BEC_app.png new file mode 100644 index 0000000..5cdf03b Binary files /dev/null and b/bec_launcher/gui/qml/Launcher/LauncherContent/images/BEC_app.png differ diff --git a/bec_launcher/gui/qml/Launcher/LauncherContent/images/BEC_comp.png b/bec_launcher/gui/qml/Launcher/LauncherContent/images/BEC_comp.png new file mode 100644 index 0000000..31214dc Binary files /dev/null and b/bec_launcher/gui/qml/Launcher/LauncherContent/images/BEC_comp.png differ diff --git a/bec_launcher/gui/qml/Launcher/LauncherContent/images/BEC_terminal.png b/bec_launcher/gui/qml/Launcher/LauncherContent/images/BEC_terminal.png new file mode 100644 index 0000000..e50f3b4 Binary files /dev/null and b/bec_launcher/gui/qml/Launcher/LauncherContent/images/BEC_terminal.png differ diff --git a/bec_launcher/gui/qml/Launcher/LauncherContent/images/images.txt b/bec_launcher/gui/qml/Launcher/LauncherContent/images/images.txt new file mode 100644 index 0000000..f8b9996 --- /dev/null +++ b/bec_launcher/gui/qml/Launcher/LauncherContent/images/images.txt @@ -0,0 +1 @@ +Default folder for image assets. diff --git a/bec_launcher/gui/qml/Launcher/LauncherContent/qmldir b/bec_launcher/gui/qml/Launcher/LauncherContent/qmldir new file mode 100644 index 0000000..f3696e5 --- /dev/null +++ b/bec_launcher/gui/qml/Launcher/LauncherContent/qmldir @@ -0,0 +1,3 @@ +App 1.0 App.qml +AppForm 1.0 AppForm.ui.qml + diff --git a/bec_launcher/gui/qml/Launcher/LauncherContent/ui/ActionCard.ui.qml b/bec_launcher/gui/qml/Launcher/LauncherContent/ui/ActionCard.ui.qml new file mode 100644 index 0000000..d370dce --- /dev/null +++ b/bec_launcher/gui/qml/Launcher/LauncherContent/ui/ActionCard.ui.qml @@ -0,0 +1,120 @@ +import QtQuick +import QtQuick.Controls +import Launcher + +Rectangle { + id: root + width: 400 + height: Constants.actionCardHeight + + property string title: "Terminal" + property string description: "Open a terminal session" + property string icon: "▶" + property url iconSource: "" + property string buttonText: "Launch" + property bool isHovered: false + + radius: Theme.radiusMedium + color: root.isHovered ? Theme.backgroundCardHover : Theme.backgroundCard + border.width: 1 + border.color: root.isHovered ? Theme.borderHover : Theme.border + + Column { + anchors.fill: parent + anchors.margins: Constants.actionCardPadding + spacing: Constants.actionCardButtonGap + + Item { + width: parent.width + height: parent.height - launchButton.height - parent.spacing + clip: true + + Row { + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + spacing: Constants.actionCardGap + + Item { + width: Constants.actionCardIconSize + height: Constants.actionCardIconSize + + Image { + anchors.fill: parent + source: root.iconSource + fillMode: Image.PreserveAspectFit + visible: root.iconSource !== "" + } + + Text { + anchors.fill: parent + text: root.icon + color: Theme.accent + font.pixelSize: 18 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + visible: root.iconSource === "" + } + } + + Column { + width: parent.width - Constants.actionCardIconSize - Constants.actionCardGap + spacing: 2 + + Text { + width: parent.width + height: Constants.actionCardTitleHeight + text: root.title + color: Theme.textPrimary + font.pixelSize: 14 + font.weight: Font.DemiBold + verticalAlignment: Text.AlignTop + } + + Text { + width: parent.width + text: root.description + color: Theme.textSecondary + font.pixelSize: 12 + wrapMode: Text.WordWrap + verticalAlignment: Text.AlignTop + } + } + } + } + + Button { + id: launchButton + width: parent.width + height: Constants.actionCardButtonHeight + text: root.buttonText + + contentItem: Text { + text: launchButton.text + color: Theme.textPrimary + font.pixelSize: 13 + font.weight: Font.DemiBold + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + radius: Theme.radiusSmall + color: launchButton.pressed ? Theme.accentPressed + : launchButton.hovered ? Theme.accentHover + : Theme.accent + } + } + } + + property alias launchButton: launchButton + property alias cardMouseArea: cardMouseArea + + MouseArea { + id: cardMouseArea + anchors.fill: parent + hoverEnabled: true + propagateComposedEvents: true + acceptedButtons: Qt.NoButton + } +} diff --git a/bec_launcher/gui/qml/Launcher/LauncherContent/ui/DeploymentCard.ui.qml b/bec_launcher/gui/qml/Launcher/LauncherContent/ui/DeploymentCard.ui.qml new file mode 100644 index 0000000..ea41bcb --- /dev/null +++ b/bec_launcher/gui/qml/Launcher/LauncherContent/ui/DeploymentCard.ui.qml @@ -0,0 +1,97 @@ +import QtQuick +import QtQuick.Layouts +import Launcher + +Rectangle { + id: root + width: 400 + height: contentColumn.implicitHeight + (Constants.deploymentCardPadding * 2) + + property string deploymentName: "SLS2-prod" + property string deploymentPath: "" + property string badgeType: "prod" + property bool isSelected: false + property bool isHovered: false + + radius: Theme.radiusMedium + color: root.isSelected ? Theme.backgroundCardSelected + : root.isHovered ? Theme.backgroundCardHover + : Theme.backgroundCard + border.width: root.isSelected ? 2 : 1 + border.color: root.isSelected ? Theme.borderSelected + : root.isHovered ? Theme.borderHover + : Theme.border + + ColumnLayout { + id: contentColumn + anchors.fill: parent + anchors.margins: Constants.deploymentCardPadding + spacing: Constants.deploymentCardGap + + RowLayout { + Layout.fillWidth: true + spacing: 10 + + ColumnLayout { + Layout.fillWidth: true + spacing: 4 + + Text { + text: root.deploymentName + color: Theme.textPrimary + font.pixelSize: 15 + font.weight: Font.DemiBold + } + + Text { + text: root.deploymentPath + color: Theme.textMuted + font.pixelSize: 11 + elide: Text.ElideMiddle + Layout.fillWidth: true + } + } + + Rectangle { + Layout.preferredWidth: badgeText.implicitWidth + 16 + Layout.preferredHeight: 22 + radius: 11 + color: root.badgeType === "prod" ? Theme.badgeProdBg + : root.badgeType === "test" ? Theme.badgeTestBg + : Theme.badgeDevBg + border.width: 1 + border.color: root.badgeType === "prod" ? Theme.badgeProd + : root.badgeType === "test" ? Theme.badgeTest + : Theme.badgeDev + + Text { + id: badgeText + anchors.centerIn: parent + text: root.badgeType.toUpperCase() + color: root.badgeType === "prod" ? Theme.badgeProd + : root.badgeType === "test" ? Theme.badgeTest + : Theme.badgeDev + font.pixelSize: 10 + font.weight: Font.Bold + } + } + + Text { + visible: root.isSelected + text: "✓" + color: Theme.accent + font.pixelSize: 16 + font.weight: Font.Bold + } + } + } + + property alias cardMouseArea: cardMouseArea + + MouseArea { + id: cardMouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + } +} diff --git a/bec_launcher/gui/qml/Launcher/LauncherContent/ui/qmldir b/bec_launcher/gui/qml/Launcher/LauncherContent/ui/qmldir new file mode 100644 index 0000000..db6edc5 --- /dev/null +++ b/bec_launcher/gui/qml/Launcher/LauncherContent/ui/qmldir @@ -0,0 +1,2 @@ +DeploymentCard 1.0 DeploymentCard.ui.qml +ActionCard 1.0 ActionCard.ui.qml diff --git a/bec_launcher/gui/qml/Launcher/qtquickcontrols2.conf b/bec_launcher/gui/qml/Launcher/qtquickcontrols2.conf new file mode 100644 index 0000000..b6ebce8 --- /dev/null +++ b/bec_launcher/gui/qml/Launcher/qtquickcontrols2.conf @@ -0,0 +1,5 @@ +[Controls] +Style=Basic + +[Material] +Theme=Light diff --git a/pyproject.toml b/pyproject.toml index 19b704c..998ad4f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ classifiers = [ "Topic :: Scientific/Engineering", ] -dependencies = [] +dependencies = [ "PySide6==6.9.0", "qtpy~=2.4"] [project.optional-dependencies] dev = [ @@ -31,6 +31,9 @@ Homepage = "https://github.com/bec-project/bec_launcher" documentation = "https://bec.readthedocs.org" changelog = "https://github.com/bec-project/bec_launcher/blob/main/CHANGELOG.md" +[project.scripts] +bec_launcher = "bec_launcher.gui.main:main" + [tool.hatch.build.targets.wheel] include = ["*"]