diff --git a/.github/actions/package/linux/action.yml b/.github/actions/package/linux/action.yml index 4096e232..7a7a9a4a 100644 --- a/.github/actions/package/linux/action.yml +++ b/.github/actions/package/linux/action.yml @@ -96,11 +96,39 @@ runs: ) fi + # Qt's own QML modules (QtQuick, QtQuick.Controls, ...) live in + # $QT_INSTALL_QML, a directory *sibling to* $QT_PLUGIN_PATH -- the + # "$QT_PLUGIN_PATH"/*/*.so glob below never reaches them, so the QML + # shell has nothing to import QtQuick from. Deploy only the modules + # launcher/qml/ actually imports (found with qmlimportscanner) + # rather than the whole Qt QML tree (Quick3D, WebEngine, etc.). + qt_root="$(dirname "${QT_PLUGIN_PATH%/}")" + QT_QML_PATH="$qt_root/qml" + qmlimportscanner_bin="$(find "$qt_root" -name qmlimportscanner -type f | head -1)" + if [[ -z "$qmlimportscanner_bin" ]]; then + echo "::error::qmlimportscanner not found under $qt_root; cannot determine which Qt QML modules to bundle." + exit 1 + fi + mapfile -t qml_modules < <( + "$qmlimportscanner_bin" -rootPath launcher/qml -importPath "$QT_QML_PATH" \ + | grep -o '"relativePath": *"[^"]*"' | sed -E 's/.*"([^"]*)"$/\1/' | sort -u + ) + if [[ ${#qml_modules[@]} -eq 0 ]]; then + echo "::error::qmlimportscanner reported no Qt QML modules for launcher/qml" + exit 1 + fi + qml_plugin_libs=() + for m in "${qml_modules[@]}"; do + mkdir -p "$INSTALL_APPIMAGE_DIR/qml/$(dirname "$m")" + cp -a "$QT_QML_PATH/$m" "$INSTALL_APPIMAGE_DIR/qml/$m" + while IFS= read -r -d '' so; do qml_plugin_libs+=("$so"); done < <(find "$QT_QML_PATH/$m" -maxdepth 1 -name '*.so' -print0) + done + sharun lib4bin \ --hard-links \ --with-hooks \ --dst-dir "$INSTALL_APPIMAGE_DIR" \ - "$INSTALL_APPIMAGE_DIR"/bin/* "$QT_PLUGIN_PATH"/*/*.so "${openssl_libs[@]}" + "$INSTALL_APPIMAGE_DIR"/bin/* "$QT_PLUGIN_PATH"/*/*.so "${qml_plugin_libs[@]}" "${openssl_libs[@]}" cp ~/bin/AppImageUpdate.AppImage "$INSTALL_APPIMAGE_DIR"/bin/ # FIXME(@YongDo-Hyun): gamemode doesn't seem to be very portable with DBus. Find a way to make it work! @@ -109,7 +137,11 @@ runs: #disable OpenGL and Vulkan launcher features until https://github.com/VHSgunzo/sharun/issues/35 echo "LAUNCHER_DISABLE_GLVULKAN=1" > "$INSTALL_APPIMAGE_DIR"/.env #makes the launcher use portals for file picking - echo "QT_QPA_PLATFORMTHEME=xdgdesktopportal" > "$INSTALL_APPIMAGE_DIR"/.env + echo "QT_QPA_PLATFORMTHEME=xdgdesktopportal" >> "$INSTALL_APPIMAGE_DIR"/.env + #points Qt at the QML modules copied in above (AppDir-relative; $APPDIR + #is exported by the AppImage runtime before AppRun/sharun ever runs) + echo "QML2_IMPORT_PATH=\$APPDIR/qml" >> "$INSTALL_APPIMAGE_DIR"/.env + echo "QML_IMPORT_PATH=\$APPDIR/qml" >> "$INSTALL_APPIMAGE_DIR"/.env ln -s org.projecttick.MeshMC.metainfo.xml "$INSTALL_APPIMAGE_DIR"/share/metainfo/org.projecttick.MeshMC.appdata.xml ln -s share/applications/org.projecttick.MeshMC.desktop "$INSTALL_APPIMAGE_DIR" ln -s share/icons/hicolor/256x256/apps/org.projecttick.MeshMC.png "$INSTALL_APPIMAGE_DIR" @@ -147,11 +179,37 @@ runs: ) fi + # See the QML deployment comment in the AppImage step above: Qt's own + # QML modules live beside $QT_PLUGIN_PATH, not under it, and the + # portable tarball needs them too (launcher/MeshMC.in points Qt at + # the copy made here via QML2_IMPORT_PATH/QML_IMPORT_PATH). + qt_root="$(dirname "${QT_PLUGIN_PATH%/}")" + QT_QML_PATH="$qt_root/qml" + qmlimportscanner_bin="$(find "$qt_root" -name qmlimportscanner -type f | head -1)" + if [[ -z "$qmlimportscanner_bin" ]]; then + echo "::error::qmlimportscanner not found under $qt_root; cannot determine which Qt QML modules to bundle." + exit 1 + fi + mapfile -t qml_modules < <( + "$qmlimportscanner_bin" -rootPath launcher/qml -importPath "$QT_QML_PATH" \ + | grep -o '"relativePath": *"[^"]*"' | sed -E 's/.*"([^"]*)"$/\1/' | sort -u + ) + if [[ ${#qml_modules[@]} -eq 0 ]]; then + echo "::error::qmlimportscanner reported no Qt QML modules for launcher/qml" + exit 1 + fi + qml_plugin_libs=() + for m in "${qml_modules[@]}"; do + mkdir -p "$INSTALL_PORTABLE_DIR/qml/$(dirname "$m")" + cp -a "$QT_QML_PATH/$m" "$INSTALL_PORTABLE_DIR/qml/$m" + while IFS= read -r -d '' so; do qml_plugin_libs+=("$so"); done < <(find "$QT_QML_PATH/$m" -maxdepth 1 -name '*.so' -print0) + done + sharun lib4bin \ --with-hooks \ --hard-links \ --dst-dir "$INSTALL_PORTABLE_DIR" \ - "$INSTALL_PORTABLE_DIR"/bin/* "$QT_PLUGIN_PATH"/*/*.so "${openssl_libs[@]}" + "$INSTALL_PORTABLE_DIR"/bin/* "$QT_PLUGIN_PATH"/*/*.so "${qml_plugin_libs[@]}" "${openssl_libs[@]}" # FIXME(@YongDo-Hyun): gamemode doesn't seem to be very portable with DBus. Find a way to make it work! find "$INSTALL_PORTABLE_DIR" -name '*gamemode*' -exec rm {} + diff --git a/.github/actions/setup-dependencies/action.yml b/.github/actions/setup-dependencies/action.yml index 3885fcb6..680a6902 100644 --- a/.github/actions/setup-dependencies/action.yml +++ b/.github/actions/setup-dependencies/action.yml @@ -121,7 +121,12 @@ runs: with: aqtversion: "==3.1.*" version: ${{ inputs.qt-version }} - modules: qtimageformats qtnetworkauth + # qtquick3d is the optional 3D cat companion's only extra Qt + # dependency (see launcher/qml/Cat/CMakeLists.txt); qtshadertools is + # listed alongside it because Quick3D bakes its shader variants + # through it at runtime, not only at build time, so it has to be + # present even though nothing here compiles a shader directly. + modules: qtimageformats qtnetworkauth qtquick3d qtshadertools cache: ${{ inputs.build-type == 'Debug' }} - name: Setup bundled OpenSSL diff --git a/.github/actions/setup-dependencies/windows/action.yml b/.github/actions/setup-dependencies/windows/action.yml index 802f98c3..f2dac993 100644 --- a/.github/actions/setup-dependencies/windows/action.yml +++ b/.github/actions/setup-dependencies/windows/action.yml @@ -56,6 +56,9 @@ runs: cmake:p ninja:p qt${{ inputs.qt-major }}-base:p + qt${{ inputs.qt-major }}-declarative:p + qt${{ inputs.qt-major }}-quick3d:p + qt${{ inputs.qt-major }}-shadertools:p qt${{ inputs.qt-major }}-svg:p qt${{ inputs.qt-major }}-imageformats:p qt${{ inputs.qt-major }}-networkauth:p diff --git a/CMakeLists.txt b/CMakeLists.txt index f6fb4e91..7b9f787a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -154,6 +154,11 @@ set(QT_VERSION_MAJOR 6) # Still a cache variable so that scripts and CI invocations that pass # -DMeshMC_QT_VERSION_MAJOR=5 fail loudly instead of quietly building Qt 6 when # they asked for something else. +# The QML user interface is always built; this only picks which interface the +# launcher opens by default. MESHMC_QML_UI=1 or =0 in the environment overrides +# it at run time. +option(MeshMC_QML_UI "Open the QML user interface instead of the QtWidgets one by default (preview)" OFF) + set(MeshMC_QT_VERSION_MAJOR "6" CACHE STRING "Major Qt version to build against (6 only)") set_property(CACHE MeshMC_QT_VERSION_MAJOR PROPERTY STRINGS "6") @@ -210,6 +215,21 @@ endif() message(STATUS "Building against Qt ${_projt_qt_version} (major version ${QT_VERSION_MAJOR})") unset(_projt_qt_version) +# Qt Quick 3D, for the optional roaming cat companion (launcher/qml/Cat/) -- +# never added to MeshMC_QT_COMPONENTS above: unlike everything there, the +# 6.4 floor must keep building without it, so it is looked for but not +# required. MeshMC_ENABLE_CAT defaults to whatever was found, and is the one +# switch that decides whether launcher/qml/CMakeLists.txt descends into +# Cat/ at all; turning it OFF by hand skips the cat even when Quick3D is +# present, but it can never turn ON what was not found. +find_package(Qt${QT_VERSION_MAJOR} OPTIONAL_COMPONENTS Quick3D) +option(MeshMC_ENABLE_CAT "Build the roaming 3D cat companion (needs Qt Quick 3D)" ${Qt${QT_VERSION_MAJOR}Quick3D_FOUND}) +if(MeshMC_ENABLE_CAT AND NOT Qt${QT_VERSION_MAJOR}Quick3D_FOUND) + message(WARNING "MeshMC_ENABLE_CAT is ON but Qt Quick3D was not found; building without the cat.") + set(MeshMC_ENABLE_CAT OFF) +endif() +message(STATUS "Cat companion (Qt Quick3D): ${MeshMC_ENABLE_CAT}") + if(UNIX) find_package(PkgConfig) if(PkgConfig_FOUND) @@ -226,6 +246,11 @@ include(QMakeQuery) QUERY_QMAKE(QT_INSTALL_PLUGINS QT_PLUGINS_DIR) QUERY_QMAKE(QT_INSTALL_LIBS QT_LIBS_DIR) QUERY_QMAKE(QT_INSTALL_LIBEXECS QT_LIBEXECS_DIR) +# Where Qt's own QML modules (QtQuick, QtQuick.Controls, ...) live in the Qt +# installation used for the build. Needed at packaging time: our own QML +# modules are compiled into the launcher, but Qt's are not, and have to be +# deployed and pointed at explicitly (see launcher/CMakeLists.txt). +QUERY_QMAKE(QT_INSTALL_QML QT_QML_DIR) # Map missing multi-config configurations to available ones. # Monorepo libraries are built with single-config (Ninja), but MeshMC uses @@ -509,6 +534,18 @@ if(UNIX AND APPLE) set(PLUGIN_DEST_DIR "${MeshMC_Name}.app/Contents/MacOS") set(RESOURCES_DEST_DIR "${MeshMC_Name}.app/Contents/Resources") set(JARS_DEST_DIR "${MeshMC_Name}.app/Contents/Resources/jars") + # Where Qt's own QML modules land inside the bundle. Contents/Resources + # (rather than next to PLUGIN_DEST_DIR in Contents/MacOS) so it matches + # what qt.conf's QmlImports key below is written relative to. + set(QML_DEST_DIR "${MeshMC_Name}.app/Contents/Resources/qml") + # qt.conf's [Paths] entries are, on macOS, resolved relative to the + # bundle's Contents/ directory (verified empirically: a value of + # "Foo.app/Contents/MacOS" written into Contents/Resources/qt.conf came + # out as Contents/Foo.app/Contents/MacOS, one "Contents/" too many) -- + # not relative to CMAKE_INSTALL_PREFIX like PLUGIN_DEST_DIR/QML_DEST_DIR + # (used for actual install() DESTINATION arguments) have to be. + set(QT_CONF_PLUGINS_DEST "MacOS") + set(QT_CONF_QML_DEST "Resources/qml") set(BUNDLE_DEST_DIR ".") set(MMCO_MODULES_DEST_DIR "${MeshMC_Name}.app/Contents/Resources/mmcmodules") @@ -622,6 +659,15 @@ elseif(WIN32) set(PLUGIN_DEST_DIR ".") set(FRAMEWORK_DEST_DIR ".") set(RESOURCES_DEST_DIR ".") + # windeployqt's own default destination for the Qt QML modules it deploys + # (see the --qmldir call in launcher/CMakeLists.txt); qt.conf's + # QmlImports key below points here too. + set(QML_DEST_DIR "qml") + # On Windows qt.conf's [Paths] entries are relative to qt.conf's own + # directory (RESOURCES_DEST_DIR, "."), same as PLUGIN_DEST_DIR/QML_DEST_DIR + # already are -- no bundle-style adjustment needed here. + set(QT_CONF_PLUGINS_DEST "${PLUGIN_DEST_DIR}") + set(QT_CONF_QML_DEST "${QML_DEST_DIR}") set(JARS_DEST_DIR "jars") # Windows keeps the flat layout it has always used: .mmco files # next to meshmc.exe under /mmcmodules. Variable exists diff --git a/branding/win_install.nsi.in b/branding/win_install.nsi.in index f4d0eddc..306b9285 100644 --- a/branding/win_install.nsi.in +++ b/branding/win_install.nsi.in @@ -384,6 +384,7 @@ Section "@MeshMC_DisplayName@" File /r "imageformats" File /r "jars" File /r "platforms" + File /r "qml" File /r "styles" File /nonfatal /r "tls" @@ -485,6 +486,7 @@ Section "Uninstall" RMDir /r $INSTDIR\imageformats RMDir /r $INSTDIR\jars RMDir /r $INSTDIR\platforms + RMDir /r $INSTDIR\qml RMDir /r $INSTDIR\styles RMDir /r $INSTDIR\tls diff --git a/launcher/Application.cpp b/launcher/Application.cpp index 916cba8c..76b6e925 100644 --- a/launcher/Application.cpp +++ b/launcher/Application.cpp @@ -19,7 +19,12 @@ #include "Application.h" #include "BuildConfig.h" +#include "plugin/PluginAuthRequestDecorator.h" +#include "ui/WidgetUiHost.h" +#include "qml/QmlShell.h" +#include "qml/QmlUiHost.h" #include "plugin/PluginManager.h" +#include "plugin/PluginSurfaceModel.h" #include "ui/MainWindow.h" #include "ui/InstanceWindow.h" @@ -329,6 +334,12 @@ namespace Application::Application(int& argc, char** argv) : QApplication(argc, argv) { + /* Before anything else: things constructed further down this function + * already reach services through LAUNCHER->, and they would see a null + * context otherwise. */ + LauncherContext::setInstance(this); + m_uiHost = std::make_unique(); + initPlatform(); if (m_status != StartingUp) return; @@ -421,6 +432,8 @@ Application::Application(int& argc, char** argv) : QApplication(argc, argv) // Do NOT pass `this` as QObject parent, or the PluginManager // will be double-freed (once by unique_ptr, once by ~QObject). m_pluginManager = std::make_unique(this, nullptr); + m_authRequestDecorator = + std::make_unique(m_pluginManager.get()); m_pluginManager->initializeAll(); if (createSetupWizard()) { @@ -879,6 +892,17 @@ void Application::initSettings() // Theming m_settings->registerSetting("IconTheme", QString("pe_colored")); m_settings->registerSetting("ApplicationTheme", QString("system")); + // The QML interface's own light/dark choice; the widget theme above + // keeps its separate list of themes. + m_settings->registerSetting("UiThemeMode", QString("dark")); + // Colour scheme of the QML interface: amethyst, ember or diamond. + m_settings->registerSetting("UiPalette", QString("grass")); + m_settings->registerSetting("UiSidebarCollapsed", false); + // Stops decorative loops (Play sheen, the cat's idle moves) for people + // who find motion distracting. + m_settings->registerSetting("UiReduceMotion", false); + m_settings->registerSetting("CatEnabled", true); + m_settings->registerSetting("CatVariant", QString("calico")); /* Screen-top menu bar. Only macOS has one; elsewhere the setting is * carried but never acted on. The second key is what this shipped as @@ -1156,8 +1180,8 @@ void Application::initSubsystems() // The updater is created before the main window on purpose: MainWindow's // constructor connects to it, so an updater made afterwards would be one - // nothing is listening to. Its dialogs therefore have no parent yet, - // which only matters for the "On Launch" check below. + // nothing is listening to. There is therefore no window to anchor its + // dialogs to yet, which only matters for the "On Launch" check below. if (updaterEnabled()) { qDebug() << "Initializing the updater"; #if defined(Q_OS_MAC) @@ -1166,7 +1190,7 @@ void Application::initSubsystems() #endif #else m_updater.reset(new MeshMCExternalUpdater( - m_mainWindow, m_rootPath, m_dataPath, + m_rootPath, m_dataPath, // Migrates the launcher's old "check on start" setting into the // updater's config, once. See the constructor. m_settings->get("AutoUpdate").toBool())); @@ -1341,8 +1365,21 @@ void Application::initSubsystems() } } +namespace +{ + // Defined further down, next to showMainWindow() -- forward-declared + // here so createSetupWizard() can skip the widget wizard when the QML + // shell will handle onboarding itself (see QmlShell::recomputeSetupSteps(), + // which runs the same rules below). + bool useQmlShell(); +} // namespace + bool Application::createSetupWizard() { + if (useQmlShell()) { + return false; + } + bool javaRequired = [&]() { QString currentHostName = QHostInfo::localHostName(); QString oldHostName = settings()->get("LastHostname").toString(); @@ -1559,6 +1596,11 @@ void Application::showFatalErrorMessage(const QString& title, Application::~Application() { + /* Stop handing out a context that is being torn down. Anything still + * running past this point has to cope with LAUNCHER being null, which is + * what the accessor documents. */ + LauncherContext::setInstance(nullptr); + // Shut down plugin system before tearing down the rest. // shutdownAll() was already called from aboutToQuit; this // is a no-op guard for any other exit path. @@ -1754,10 +1796,25 @@ bool Application::launch(InstancePtr instance, LaunchMode mode, controller->start(); return true; } else if (instance->isRunning()) { - showInstanceWindow(instance, "console"); + // Same widget behaviour as before (showInstanceWindow(instance, + // "console")) when the QML shell is off - see showInstanceLog()'s + // own doc comment; under it, this raises the QML window on the + // instance's Log tab instead of a widget InstanceWindow. + showInstanceLog(instance); return true; } else if (instance->canEdit()) { - showInstanceWindow(instance); + // Same reasoning as the isRunning() branch above, but not through + // showInstanceLog(): its widget fallback is always + // showInstanceWindow(instance, "console"), while this branch's + // own widget behaviour opens on the window's own default page + // instead (showInstanceWindow(instance), no explicit page) - kept + // exactly as it was when the QML shell is off. + if (usingQmlShell()) { + m_qmlShell->show(); + m_qmlShell->showInstanceLogRequested(instance->id()); + } else { + showInstanceWindow(instance); + } return true; } return false; @@ -1859,7 +1916,7 @@ bool Application::reportUpdateMarkers() { const QString updateLog = UpdateLockFile::updateLogPath(m_dataPath); - const auto logContents = [&updateLog]() -> QString { + const auto logContents = [updateLog]() -> QString { QFile file(updateLog); if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) return {}; @@ -1868,6 +1925,15 @@ bool Application::reportUpdateMarkers() return contents; }; + /* This runs from init(), before useQmlShell()/showMainWindow() has + * chosen a UI -- so under the QML shell there is no window yet to + * answer a UiHost call either (see QmlUiHost's PRESENTER READINESS + * comment). Queue what would have been a QMessageBox instead of + * showing one, and let showMainWindow() run the queue once its + * QmlUiHost reports presenterReady() -- see m_pendingQmlUpdateReports. + * The classic path below is entirely unchanged when this is false. */ + const bool qml = useQmlShell(); + // A lock left behind means an update started and never finished, so this // installation may be a mix of two versions. That is not something to // carry on from silently. @@ -1876,91 +1942,130 @@ bool Application::reportUpdateMarkers() UpdateLockFile::Contents lock; UpdateLockFile::read(lockPath, &lock); - QMessageBox box(QMessageBox::Warning, tr("Update In Progress"), - tr("This installation has an update lock file at: %1\n" - "\n" - "Timestamp: %2\n" - "Updating from version %3 to %4\n" - "Target install path: %5\n" - "Data path: %6\n" - "\n" - "This usually means an update attempt failed. " - "Please make sure your installation still works " - "before continuing.\n" - "The updater log at:\n" - "%7\n" - "has the details of the last attempt.\n" - "\n" - "To delete this lock and continue, choose " - "\"Ignore\".") - .arg(QDir::toNativeSeparators(lockPath), - lock.timestamp.toString(Qt::ISODate), - lock.from, lock.to, lock.target, - lock.dataPath, - QDir::toNativeSeparators(updateLog)), - QMessageBox::Ignore | QMessageBox::Abort); - box.setDefaultButton(QMessageBox::Abort); - box.setModal(true); - box.setDetailedText(logContents()); - box.setMinimumWidth(460); - box.adjustSize(); - - if (box.exec() != QMessageBox::Ignore) { - qDebug() << "Exiting because an update lock file is present."; - return false; + const QString text = + tr("This installation has an update lock file at: %1\n" + "\n" + "Timestamp: %2\n" + "Updating from version %3 to %4\n" + "Target install path: %5\n" + "Data path: %6\n" + "\n" + "This usually means an update attempt failed. " + "Please make sure your installation still works " + "before continuing.\n" + "The updater log at:\n" + "%7\n" + "has the details of the last attempt.\n" + "\n" + "To delete this lock and continue, choose " + "\"Ignore\".") + .arg(QDir::toNativeSeparators(lockPath), + lock.timestamp.toString(Qt::ISODate), + lock.from, lock.to, lock.target, + lock.dataPath, + QDir::toNativeSeparators(updateLog)); + + if (qml) { + m_pendingQmlUpdateReports.append([this, lockPath, text]() { + const bool ignore = uiHost()->confirm( + tr("Update In Progress"), text, UiHost::Severity::Warning, + tr("Ignore"), tr("Quit")); + if (!ignore) { + qDebug() + << "Quitting because an update lock file is present."; + quit(); + return; + } + QFile::remove(lockPath); + }); + } else { + QMessageBox box(QMessageBox::Warning, tr("Update In Progress"), + text, QMessageBox::Ignore | QMessageBox::Abort); + box.setDefaultButton(QMessageBox::Abort); + box.setModal(true); + box.setDetailedText(logContents()); + box.setMinimumWidth(460); + box.adjustSize(); + + if (box.exec() != QMessageBox::Ignore) { + qDebug() << "Exiting because an update lock file is present."; + return false; + } + QFile::remove(lockPath); } - QFile::remove(lockPath); } const QString failMarker = UpdateLockFile::markerPath( m_dataPath, QLatin1String(UpdateLockFile::kFailMarkerName)); if (QFileInfo::exists(failMarker)) { - QMessageBox box(QMessageBox::Warning, tr("Update Failed"), - tr("An update attempt failed.\n" - "\n" - "Please make sure your installation still works " - "before continuing.\n" - "The updater log at:\n" - "%1\n" - "has the details of the last attempt.") - .arg(QDir::toNativeSeparators(updateLog)), - QMessageBox::Ignore | QMessageBox::Abort); - box.setDefaultButton(QMessageBox::Abort); - box.setModal(true); - box.setDetailedText(logContents()); - box.setMinimumWidth(460); - box.adjustSize(); - - if (box.exec() != QMessageBox::Ignore) { - qDebug() << "Exiting because the last update failed."; - return false; + const QString text = tr("An update attempt failed.\n" + "\n" + "Please make sure your installation still " + "works before continuing.\n" + "The updater log at:\n" + "%1\n" + "has the details of the last attempt.") + .arg(QDir::toNativeSeparators(updateLog)); + + if (qml) { + m_pendingQmlUpdateReports.append([this, failMarker, text]() { + const bool ignore = uiHost()->confirm( + tr("Update Failed"), text, UiHost::Severity::Warning, + tr("Ignore"), tr("Quit")); + if (!ignore) { + qDebug() << "Quitting because the last update failed."; + quit(); + return; + } + QFile::remove(failMarker); + }); + } else { + QMessageBox box(QMessageBox::Warning, tr("Update Failed"), text, + QMessageBox::Ignore | QMessageBox::Abort); + box.setDefaultButton(QMessageBox::Abort); + box.setModal(true); + box.setDetailedText(logContents()); + box.setMinimumWidth(460); + box.adjustSize(); + + if (box.exec() != QMessageBox::Ignore) { + qDebug() << "Exiting because the last update failed."; + return false; + } + QFile::remove(failMarker); } - QFile::remove(failMarker); } const QString successMarker = UpdateLockFile::markerPath( m_dataPath, QLatin1String(UpdateLockFile::kSuccessMarkerName)); if (QFileInfo::exists(successMarker)) { - // Shown without blocking startup: the news is good, and the details - // are there for anyone who wants them. - auto* box = new QMessageBox( - QMessageBox::Information, tr("Update Succeeded"), - tr("The update succeeded.\n" - "\n" - "You are now running %1.\n" - "The updater log at:\n" - "%2\n" - "has the details.") - .arg(BuildConfig.printableVersionString(), - QDir::toNativeSeparators(updateLog)), - QMessageBox::Ok); - box->setDefaultButton(QMessageBox::Ok); - box->setDetailedText(logContents()); - box->setAttribute(Qt::WA_DeleteOnClose); - box->setMinimumWidth(460); - box->adjustSize(); - box->open(); - + const QString text = tr("The update succeeded.\n" + "\n" + "You are now running %1.\n" + "The updater log at:\n" + "%2\n" + "has the details.") + .arg(BuildConfig.printableVersionString(), + QDir::toNativeSeparators(updateLog)); + + if (qml) { + m_pendingQmlUpdateReports.append([this, text]() { + uiHost()->message(tr("Update Succeeded"), text, + UiHost::Severity::Information); + }); + } else { + // Shown without blocking startup: the news is good, and the + // details are there for anyone who wants them. + auto* box = new QMessageBox(QMessageBox::Information, + tr("Update Succeeded"), text, + QMessageBox::Ok); + box->setDefaultButton(QMessageBox::Ok); + box->setDetailedText(logContents()); + box->setAttribute(Qt::WA_DeleteOnClose); + box->setMinimumWidth(460); + box->adjustSize(); + box->open(); + } QFile::remove(successMarker); } @@ -1992,7 +2097,10 @@ void Application::controllerSucceeded() // quit when there are no more windows. if (shouldExitNow()) { m_status = Status::Succeeded; - exit(0); + // Qualified explicitly so a future refactor of this class cannot + // silently turn this into ::exit() (libc, no Qt shutdown) by adding + // a member or free function named exit() that shadows this call. + QCoreApplication::exit(0); } } @@ -2012,7 +2120,8 @@ void Application::controllerFailed(const QString& error) // quit when there are no more windows. if (shouldExitNow()) { m_status = Status::Failed; - exit(1); + // See controllerSucceeded()'s exit(0) for why this is qualified. + QCoreApplication::exit(1); } } @@ -2043,6 +2152,14 @@ namespace evt.page_list_handle = &pages; APPLICATION->pluginManager()->dispatchHook( MMCO_HOOK_UI_GLOBAL_SETTINGS_PAGES, &evt); + + /* ABI 5 — every MMCO_UI_ANCHOR_GLOBAL_SETTINGS surface + * (ui_surface_create) is stacked as a titled section + * inside one host-built "Plugins" page, appended here + * alongside whatever the raw hook above still added. */ + if (BasePage* pluginsPage = + APPLICATION->pluginManager()->createGlobalSettingsPluginsPage()) + pages.append(pluginsPage); } return pages; } @@ -2079,8 +2196,241 @@ void Application::registerGlobalSettingsPage(std::function creator) } } +namespace +{ + /* The QML user interface is a preview. It is always compiled -- so it + * cannot rot unnoticed -- but only shown when asked for. The build option + * MeshMC_QML_UI picks the default; MESHMC_QML_UI in the environment + * overrides it either way, so both interfaces can be compared from the + * same binary. */ + bool useQmlShell() + { +#ifdef MESHMC_QML_UI_DEFAULT + bool fallback = true; +#else + bool fallback = false; +#endif + const QByteArray env = qgetenv("MESHMC_QML_UI"); + if (env.isEmpty()) + return fallback; + return env != "0"; + } +} // namespace + MainWindow* Application::showMainWindow(bool minimized) { + if (useQmlShell()) { + if (!m_qmlShell) { + m_qmlShell = std::make_unique(); + + /* QmlShell cannot see PluginManager (MeshMC_qml does not link + * MeshMC_logic, which is where the plugin host lives) -- this + * factory is the bridge, installed once here the same way the + * signal connections below wire up the widget-side actions + * QmlShell itself cannot reach. Harmless to install again on a + * second showMainWindow() call: it is process-wide state, and + * this whole block already only runs once per m_qmlShell. */ + QmlShell::setPluginSurfaceFactory( + [this](int anchor, const QString& anchorContext) -> QObject* { + if (!m_pluginManager) { + return nullptr; + } + return QmlShell::expose(new PluginSurfaceModel( + m_pluginManager.get(), anchor, anchorContext)); + }); + + /* Counted and closed through the same path as MainWindow, so the + * launcher quits when its last window goes, as it always has. */ + connect(m_qmlShell.get(), &QmlShell::closed, this, + &Application::on_windowClose); + + /* QmlShell cannot see widget code, so it only emits these + * signals; the actual actions - the same ones the widget menus + * already run - are wired up here. */ + connect(m_qmlShell.get(), &QmlShell::launchRequested, this, + [this](const QString& id) { + if (auto inst = instances()->getInstanceById(id)) { + launch(inst); + } + }); + connect(m_qmlShell.get(), &QmlShell::killRequested, this, + [this](const QString& id) { + if (auto inst = instances()->getInstanceById(id)) { + kill(inst); + } + }); + connect(m_qmlShell.get(), &QmlShell::editRequested, this, + [this](const QString& id) { + /* The instance page's "Classic editor" button that + * used to reach here is gone (see InstancePage.qml) + * now that the page covers servers, backups, data + * packs, worlds and the managed-pack section + * directly. Refuse rather than open a widget + * InstanceWindow regardless -- same reasoning as + * settingsRequested/accountsRequested below: the + * hard rule is no Qt Widgets window opens under the + * QML shell, full stop. */ + qWarning() + << "Application: editRequested(" << id + << ") ignored under the QML shell -- the " + "instance page covers this instance's " + "content directly now"; + }); + connect(m_qmlShell.get(), &QmlShell::joinServerRequested, this, + [this](const QString& id, const QString& address) { + if (auto inst = instances()->getInstanceById(id)) { + launch(inst, LaunchMode::Normal, + std::make_shared( + MinecraftServerTarget::parse(address, + false))); + } + }); + connect(m_qmlShell.get(), &QmlShell::folderRequested, this, + [this](const QString& id) { + if (auto inst = instances()->getInstanceById(id)) { + DesktopServices::openDirectory(inst->instanceRoot(), + true); + } + }); + connect(m_qmlShell.get(), &QmlShell::settingsRequested, this, + [](const QString& page) { + /* Every settings page has a QML section of its own + * now -- proxy-settings/external-tools/log-upload + * (the last rows that still opened the classic + * dialog, under Settings' "More" section) moved to + * SettingsPage.qml alongside accounts/language/..., + * so nothing under the QML shell should ever reach + * this signal any more. Refuse rather than open + * ShowGlobalSettings(nullptr, ...) regardless: that + * null parent is the same defect class as + * createInstanceRequested below, and the hard rule + * is no Qt Widgets dialog opens under the QML + * shell, full stop -- not "only the ones without a + * QML page yet". */ + qWarning() + << "Application: settingsRequested(" << page + << ") ignored under the QML shell -- open the " + "matching SettingsPage/AccountsPage section " + "instead"; + }); + connect(m_qmlShell.get(), &QmlShell::accountsRequested, this, + []() { + /* Full parity in QML already (see AccountsPage.qml); + * nothing currently emits this signal, but refuse it + * rather than open ShowGlobalSettings(nullptr, ...) + * if something someday does. */ + qWarning() << "Application: accountsRequested ignored " + "under the QML shell -- use the Accounts " + "page instead"; + }); + connect(m_qmlShell.get(), &QmlShell::createInstanceRequested, this, + [this]() { + /* This is the reported SIGSEGV: with no MainWindow + * under the QML shell (see this method's own doc + * comment), MainWindow::createInstanceFromDialog() + * used to be called with a null parent, constructing + * NewInstanceDialog's whole ~15-file widget stack + * that was never built or tested that way. The + * import lane is building a QML replacement for the + * button that emits this; until it ships, refuse + * instead of ever reaching that dialog under QML. */ + qWarning() << "Application: createInstanceRequested " + "ignored under the QML shell (no QML " + "import screen yet)"; + uiHost()->message( + tr("Not available yet"), + tr("Importing a modpack from a file, or browsing " + "other platforms, isn't available in this " + "preview interface yet."), + UiHost::Severity::Information); + }); + + if (m_qmlShell->show(minimized)) { + m_openWindows++; + + /* Runs whatever reportUpdateMarkers() queued at init() (see + * m_pendingQmlUpdateReports) the moment QmlUiHost can + * actually show a request. Wired here rather than right + * after QmlShell's construction above: QmlShell only builds + * its QmlUiHost inside show() (called just above), so + * m_qmlShell->uiHost() is null before this point and the + * qobject_cast below would always fail silently. show() + * having just returned true guarantees the host now + * exists. */ + if (auto* host = qobject_cast(m_qmlShell->uiHost())) { + connect(host, &QmlUiHost::presenterReadyChanged, this, + [this, host]() { + if (!host->presenterReady() || + m_pendingQmlUpdateReports.isEmpty()) { + return; + } + const auto pending = + std::move(m_pendingQmlUpdateReports); + m_pendingQmlUpdateReports.clear(); + for (const auto& report : pending) { + report(); + } + }); + /* presenterReady() may already be true by the time this + * connection is made (e.g. the QML window finished its + * Component.onCompleted during the load() call above) -- + * presenterReadyChanged() would then never fire again to + * trigger the flush. Run the queue once here too; the + * lambda above is a no-op if it later fires with nothing + * left queued. */ + if (host->presenterReady() && + !m_pendingQmlUpdateReports.isEmpty()) { + const auto pending = + std::move(m_pendingQmlUpdateReports); + m_pendingQmlUpdateReports.clear(); + for (const auto& report : pending) { + report(); + } + } + } + + /* The widget MainWindow fires MMCO_HOOK_UI_MAIN_READY + * itself, from its own constructor (see + * ui/MainWindow.cpp) -- the QML shell has no equivalent + * place to hang that off of, so this is it: once, right + * after the shell's root window is up, with every + * widget handle in the payload null (see + * PluginHooks.h). Plugins are already initialised by + * this point -- initializeAll() runs during + * Application::init(), well before showMainWindow() is + * ever reachable -- so every hook registration this + * dispatch could reach is already in place. This whole + * branch only runs the first time m_qmlShell is + * created, so the hook fires at most once per shell. */ + if (m_pluginManager) { + MMCOUiMainReadyPayload mainReady{}; + m_pluginManager->dispatchHook(MMCO_HOOK_UI_MAIN_READY, + &mainReady); + } + return nullptr; + } + /* A QML load failure must not leave the user with nothing on + * screen: fall through to the widget window instead. */ + qWarning() << "QML shell failed to load; using the widget window"; + m_qmlShell.reset(); + + if (!m_pendingQmlUpdateReports.isEmpty()) { + /* Never ran above -- the QmlUiHost that would have shown + * them never came up. uiHost() now falls back to the widget + * host since m_qmlShell is gone, so these still reach the + * user instead of being silently dropped. */ + const auto pending = std::move(m_pendingQmlUpdateReports); + m_pendingQmlUpdateReports.clear(); + for (const auto& report : pending) { + report(); + } + } + } else { + m_qmlShell->show(minimized); + return nullptr; + } + } + if (m_mainWindow) { m_mainWindow->setWindowState(m_mainWindow->windowState() & ~Qt::WindowMinimized); @@ -2111,6 +2461,11 @@ MainWindow* Application::showMainWindow(bool minimized) return m_mainWindow; } +QWindow* Application::qmlShellWindow() const +{ + return m_qmlShell ? m_qmlShell->window() : nullptr; +} + InstanceWindow* Application::showInstanceWindow(InstancePtr instance, QString page) { @@ -2169,7 +2524,8 @@ void Application::on_windowClose() } // quit when there are no more windows. if (shouldExitNow()) { - exit(0); + // See controllerSucceeded()'s exit(0) for why this is qualified. + QCoreApplication::exit(0); } } @@ -2293,3 +2649,50 @@ const QString Application::javaPath() { return m_settings->get("JavaDir").toString(); } + +AuthRequestDecorator* Application::authRequestDecorator() const +{ + return m_authRequestDecorator.get(); +} + +UiHost* Application::uiHost() const +{ + /* Prefer the QML shell's own UiHost while it is the active UI (see + * useQmlShell() and showMainWindow()): m_qmlShell is only non-null + * once its show() has succeeded, and uiHostInterface() on it is only + * non-null once show() has created it *and* some QML item has called + * setPresenterReady(true) on it (see QmlUiHost's class comment) -- + * both conditions this checks implicitly by falling through to the + * widget host otherwise. Every call site reaches this fresh + * (LAUNCHER->uiHost()->...) rather than caching the pointer, so + * switching which one answers from one call to the next is safe. */ + if (m_qmlShell) { + if (auto* host = m_qmlShell->uiHostInterface()) { + return host; + } + } + return m_uiHost.get(); +} + +bool Application::usingQmlShell() const +{ + return m_qmlShell && m_qmlShell->uiHostInterface() != nullptr; +} + +void Application::showInstanceLog(InstancePtr instance) +{ + if (!instance) { + return; + } + if (usingQmlShell()) { + // The request can arrive with the QML window minimized or behind + // others (a background crash, or - see launch()'s own + // isRunning()/canEdit() branches - a second Play click while + // nothing new needs launching): raise it the same way clicking + // the dock/taskbar icon would, so the log actually gets seen. + m_qmlShell->show(); + m_qmlShell->showInstanceLogRequested(instance->id()); + return; + } + showInstanceWindow(instance, "console"); +} diff --git a/launcher/Application.h b/launcher/Application.h index 57d9b4e0..3b8ff86c 100644 --- a/launcher/Application.h +++ b/launcher/Application.h @@ -28,14 +28,18 @@ #include #include #include +#include #include #include "Logging.h" #include "minecraft/launch/MinecraftServerTarget.h" +#include "core/LauncherContext.h" class LaunchController; +class QmlShell; +class QWindow; class LocalPeer; class InstanceWindow; class InstanceSettingsPage; @@ -88,7 +92,7 @@ enum class LaunchMode #endif #define APPLICATION (static_cast(QCoreApplication::instance())) -class Application : public QApplication +class Application : public QApplication, public LauncherContext { // friends for the purpose of limiting access to deprecated stuff Q_OBJECT @@ -104,7 +108,7 @@ class Application : public QApplication return m_pluginManager.get(); } - std::shared_ptr settings() const + std::shared_ptr settings() const override { return m_settings; } @@ -114,7 +118,7 @@ class Application : public QApplication return startTime.msecsTo(QDateTime::currentDateTime()); } - QIcon getThemedIcon(const QString& name); + QIcon getThemedIcon(const QString& name) override; void setIconTheme(const QString& name); @@ -136,16 +140,16 @@ class Application : public QApplication void triggerUpdateCheck(); - std::shared_ptr translations(); + std::shared_ptr translations() override; - std::shared_ptr javalist(); + std::shared_ptr javalist() override; - std::shared_ptr instances() const + std::shared_ptr instances() const override { return m_instances; } - std::shared_ptr icons() const + std::shared_ptr icons() const override { return m_icons; } @@ -155,12 +159,12 @@ class Application : public QApplication return m_mcedit.get(); } - shared_qobject_ptr accounts() const + shared_qobject_ptr accounts() const override { return m_accounts; } - QString msaClientId() const; + QString msaClientId() const override; Status status() const { @@ -173,15 +177,26 @@ class Application : public QApplication } void updateProxySettings(QString proxyTypeStr, QString addr, int port, - QString user, QString password); + QString user, QString password) override; - shared_qobject_ptr network(); + shared_qobject_ptr network() override; - shared_qobject_ptr metacache(); + shared_qobject_ptr metacache() override; - shared_qobject_ptr metadataIndex(); + shared_qobject_ptr metadataIndex() override; - QString getJarsPath(); + QString getJarsPath() override; + + AuthRequestDecorator* authRequestDecorator() const override; + + UiHost* uiHost() const override; + + /* Whether the QML shell is up and ready to answer -- the same check + * uiHost() makes before it will route a UiHost call to QmlUiHost (see + * that method's comment). Callers that need to choose between a + * widget window and a QML-side equivalent themselves (LaunchController's + * account picker, for one) use this instead of duplicating the check. */ + bool usingQmlShell() const; /// this is the root of the 'installation'. Used for automatic updates const QString& root() @@ -199,12 +214,28 @@ class Application : public QApplication InstanceWindow* showInstanceWindow(InstancePtr instance, QString page = QString()); + + /* What a launch reaches for instead of showInstanceWindow() when it + * wants to show an instance's console: routes to the QML shell's own + * instance-log signal while it is the active UI (usingQmlShell()), or + * to the widget console window (showInstanceWindow(), selecting its + * Log page) otherwise -- so a launch never raises a widget window + * under the QML shell. */ + void showInstanceLog(InstancePtr instance); MainWindow* showMainWindow(bool minimized = false); MainWindow* mainWindow() const { return m_mainWindow; } + /* The QML shell's top-level window when it is the active UI (see + * useQmlShell() in Application.cpp) and has been shown; null when + * the widget MainWindow is in use instead, or before either has + * been shown. PluginManager uses this to generalise main-window + * handling (show/hide/close-filter) to whichever UI is actually on + * screen -- see PluginManager::resolveShellWindow(). */ + QWindow* qmlShellWindow() const; + void updateIsRunning(bool running); bool updatesAreAllowed(); @@ -325,6 +356,29 @@ class Application : public QApplication SetupWizard* m_setupWizard = nullptr; std::unique_ptr m_pluginManager; + /* Built alongside m_pluginManager, so it is null in builds without the + * plugin host (MeshMC_PLUGINS is OFF by default). */ + std::unique_ptr m_authRequestDecorator; + + /* Built before anything that could ask the user a question, because + * uiHost() promises never to return null. */ + std::unique_ptr m_uiHost; + + /* The QML user interface, when it is the one in use instead of + * MainWindow. See useQmlShell(). */ + std::unique_ptr m_qmlShell; + + /* Set by reportUpdateMarkers() when init() (before useQmlShell() is + * even consulted) finds an update lock/fail/success marker and the QML + * shell is about to be used: there is no QML window yet to answer + * through uiHost() at that point (see QmlUiHost's PRESENTER READINESS), + * so showing the classic QMessageBox there instead would be exactly + * the widget-under-QML leak the whole audit is about. Queued here and + * run once showMainWindow()'s QmlUiHost reports presenterReady(); run + * through the widget host instead if the QML shell then fails to load + * (see showMainWindow()). Empty when there is nothing to report. */ + QList> m_pendingQmlUpdateReports; + public: QString m_instanceIdToLaunch; QString m_serverToJoin; diff --git a/launcher/BaseInstance.h b/launcher/BaseInstance.h index 47a7bea7..df109f6f 100644 --- a/launcher/BaseInstance.h +++ b/launcher/BaseInstance.h @@ -304,6 +304,32 @@ class BaseInstance : public QObject, virtual QString typeName() const = 0; + /** + * The Minecraft version this instance runs, e.g. "1.21.4". + * + * Empty for an instance type that has no such concept, which is the + * default here. MinecraftInstance overrides this; kept on the base + * class so generic code (the QML instance list) can ask any instance + * without a dynamic_cast. + */ + virtual QString gameVersion() const + { + return {}; + } + + /** + * Human name of the mod loader this instance uses ("Fabric", "Forge", + * ...), or empty when none is installed or this instance type has no + * such concept. + * + * Overridden by MinecraftInstance, for the same reason as + * gameVersion(). + */ + virtual QString modLoaderName() const + { + return {}; + } + bool hasVersionBroken() const { return m_hasBrokenVersion; diff --git a/launcher/CMakeLists.txt b/launcher/CMakeLists.txt index 174ef3d8..f208572e 100644 --- a/launcher/CMakeLists.txt +++ b/launcher/CMakeLists.txt @@ -7,6 +7,59 @@ project(application) include (UnitTest) set(CORE_SOURCES + # The services the core is allowed to reach for, and the interfaces it + # declares for things it needs but must not depend on. Nothing here may + # mention QtWidgets. + core/LauncherContext.h + core/LauncherContext.cpp + + # QDesktopServices lives in QtGui; opening a URL or a folder is not UI. + DesktopServices.h + DesktopServices.cpp + core/AuthRequestDecorator.h + core/UiHost.h + + # WCAG 2.1 contrast math for the token-based theme. Confined to QtGui, + # same as the block above -- no QtWidgets, no ui/. + theme/Contrast.h + theme/Contrast.cpp + theme/ThemePalette.h + theme/ThemePalette.cpp + + # What the QML instance grid needs in place of the widget proxy and + # QItemSelectionModel. Core, like the blocks above: no QtWidgets, no ui/. + models/InstanceFilterModel.h + models/InstanceFilterModel.cpp + models/IdSelectionModel.h + models/IdSelectionModel.cpp + models/SettingsAdapter.h + models/SettingsAdapter.cpp + models/InstanceDetails.h + models/InstanceDetails.cpp + models/ContentBrowser.h + models/ContentBrowser.cpp + models/LoaderInstaller.h + models/LoaderInstaller.cpp + models/OtherLogsModel.h + models/OtherLogsModel.cpp + models/KeyValueFilterModel.h + models/KeyValueFilterModel.cpp + models/AccountsController.h + models/AccountsController.cpp + models/NewInstanceController.h + models/NewInstanceController.cpp + models/RecentWorldsModel.h + models/RecentWorldsModel.cpp + models/ServersListModel.h + models/ServersListModel.cpp + models/BackupController.h + models/BackupController.cpp + models/WorldDataPacksController.h + models/WorldDataPacksController.cpp + models/ManagedPackController.h + models/ManagedPackController.cpp + modplatform/BlockedMod.h + # LOGIC - Base classes and infrastructure BaseInstaller.h BaseInstaller.cpp @@ -62,8 +115,6 @@ set(CORE_SOURCES # from costing a per-file platform lookup. FileIgnoreProxy.h FileIgnoreProxy.cpp - FastFileIconProvider.h - FastFileIconProvider.cpp # String filters Filter.h @@ -182,6 +233,8 @@ set(LAUNCH_SOURCES launch/LaunchStep.h launch/LaunchTask.cpp launch/LaunchTask.h + launch/LaunchProgressTracker.cpp + launch/LaunchProgressTracker.h launch/LogModel.cpp launch/LogModel.h ) @@ -270,6 +323,10 @@ set(ICONS_SOURCES # Icons System and related code icons/IconUtils.h icons/IconUtils.cpp + icons/MMCIcon.h + icons/MMCIcon.cpp + icons/IconList.h + icons/IconList.cpp ) # Support for Minecraft instances and launch @@ -381,8 +438,6 @@ set(MINECRAFT_SOURCES minecraft/ParseUtils.h minecraft/ProfileUtils.cpp minecraft/ProfileUtils.h - minecraft/ShortcutUtils.cpp - minecraft/ShortcutUtils.h minecraft/Library.cpp minecraft/Library.h minecraft/MojangDownloadInfo.h @@ -499,8 +554,15 @@ set(SCREENSHOTS_SOURCES screenshots/ImgurUpload.cpp screenshots/ImgurAlbumCreation.h screenshots/ImgurAlbumCreation.cpp + screenshots/ScreenshotListModel.h + screenshots/ScreenshotListModel.cpp ) +add_unit_test(ScreenshotListModel + SOURCES screenshots/ScreenshotListModel_test.cpp + LIBS MeshMC_core + ) + set(TASKS_SOURCES # Tasks tasks/Task.h @@ -509,6 +571,8 @@ set(TASKS_SOURCES tasks/ConcurrentTask.cpp tasks/SequentialTask.h tasks/SequentialTask.cpp + tasks/TaskWatcher.h + tasks/TaskWatcher.cpp ) set(SETTINGS_SOURCES @@ -567,6 +631,13 @@ set(TRANSLATIONS_SOURCES translations/POTranslator.cpp ) +# Model bookkeeping only (roleNames(), the builtin default row) -- no +# network, same reasoning as the IconList test below. +add_unit_test(TranslationsModel + SOURCES translations/TranslationsModel_test.cpp + LIBS MeshMC_core + ) + set(TOOLS_SOURCES # Tools tools/BaseExternalTool.cpp @@ -662,6 +733,8 @@ set(MODRINTH_SOURCES modplatform/modrinth/ModrinthApi.cpp modplatform/modrinth/ModrinthPackExportTask.h modplatform/modrinth/ModrinthPackExportTask.cpp + modplatform/modrinth/ModrinthModpackModel.h + modplatform/modrinth/ModrinthModpackModel.cpp ) set(CONTENT_DOWNLOAD_SOURCES @@ -694,6 +767,10 @@ add_unit_test(PackContents ) set(PLUGIN_SOURCES + # Supplies core-declared interfaces that need the plugin host to run. + plugin/PluginAuthRequestDecorator.h + plugin/PluginAuthRequestDecorator.cpp + # MMCO Plugin System plugin/MMCOFormat.h plugin/PluginHooks.h @@ -704,12 +781,16 @@ set(PLUGIN_SOURCES plugin/PluginLoader.cpp plugin/PluginManager.h plugin/PluginManager.cpp + plugin/PluginUiRenderer.h + plugin/PluginUiRenderer.cpp plugin/PluginHookTask.h plugin/PluginHookTask.cpp plugin/PluginSignature.h plugin/PluginSignature.cpp plugin/PluginDependencyResolver.h plugin/PluginDependencyResolver.cpp + plugin/PluginSurfaceModel.h + plugin/PluginSurfaceModel.cpp ) add_unit_test(Index @@ -749,7 +830,6 @@ set(LOGIC_SOURCES ${ATLAUNCHER_SOURCES} ${MODRINTH_SOURCES} ${CONTENT_DOWNLOAD_SOURCES} - ${PLUGIN_SOURCES} ) SET(MESHMC_SOURCES @@ -760,8 +840,6 @@ SET(MESHMC_SOURCES ApplicationMessage.cpp # GUI - general utilities - DesktopServices.h - DesktopServices.cpp VersionProxyModel.h VersionProxyModel.cpp HoeDown.h @@ -788,10 +866,6 @@ SET(MESHMC_SOURCES ${CMAKE_BINARY_DIR}/${MeshMC_AppBinaryName}.qrc # Icons - icons/MMCIcon.h - icons/MMCIcon.cpp - icons/IconList.h - icons/IconList.cpp # GUI - windows ui/GuiUtil.h @@ -799,6 +873,12 @@ SET(MESHMC_SOURCES ui/ColorCache.h ui/ColorCache.cpp ui/MainWindow.h + ui/ShortcutUtils.cpp + ui/ShortcutUtils.h + ui/FastFileIconProvider.cpp + ui/FastFileIconProvider.h + ui/WidgetUiHost.cpp + ui/WidgetUiHost.h ui/MainWindow.cpp ui/MacMenuBar.h ui/MacMenuBar.cpp @@ -841,15 +921,15 @@ SET(MESHMC_SOURCES ui/themes/CatPack.h # Processes - LaunchController.h - LaunchController.cpp + ui/LaunchController.h + ui/LaunchController.cpp # page provider for instances - InstancePageProvider.h + ui/InstancePageProvider.h # Common java checking UI - JavaCommon.h - JavaCommon.cpp + ui/JavaCommon.h + ui/JavaCommon.cpp # GUI - paged dialog base ui/pages/BasePage.h @@ -1196,7 +1276,22 @@ if(WIN32) endif() # Add executable -add_library(MeshMC_logic STATIC ${LOGIC_SOURCES} ${MESHMC_SOURCES} ${MESHMC_UI} ${MESHMC_SHARED_UI} ${MESHMC_RESOURCES}) +# The launcher is two libraries. +# +# MeshMC_core is everything that works without a user interface: instances, +# downloads, authentication, mod platforms, settings. It must not link +# QtWidgets -- that is what lets a QML shell reuse it -- and it is not allowed +# to know the plugin host or anything under ui/ exists. +# +# MeshMC_logic is the plugin host and the widget user interface, built on top +# of the core. It keeps its old name so the application and the unit tests that +# link it need no change. The plugin host sits here rather than in the core for +# now: PluginManager renders plugin-supplied widgets and includes MainWindow, so +# it and the widget UI depend on each other until the declarative plugin ABI +# replaces that. +add_library(MeshMC_core STATIC ${LOGIC_SOURCES}) +add_library(MeshMC_logic STATIC ${PLUGIN_SOURCES} ${MESHMC_SOURCES} ${MESHMC_UI} ${MESHMC_SHARED_UI} ${MESHMC_RESOURCES}) +target_link_libraries(MeshMC_logic MeshMC_core) # Declared after MeshMC_logic because it links against it. add_unit_test(CustomTheme @@ -1204,11 +1299,191 @@ add_unit_test(CustomTheme LIBS MeshMC_logic ) +# MeshMC_logic: the plugin host lives there until the declarative plugin ABI +# lets it move out of the widget UI's target. +add_unit_test(PluginLoader + SOURCES plugin/PluginLoader_test.cpp + LIBS MeshMC_logic + ) + +add_unit_test(PluginUiRenderer + SOURCES plugin/PluginUiRenderer_test.cpp + LIBS MeshMC_logic + ) + +add_unit_test(PluginSurfaceModel + SOURCES plugin/PluginSurfaceModel_test.cpp + LIBS MeshMC_logic + ) + +add_unit_test(Contrast + SOURCES theme/Contrast_test.cpp + LIBS MeshMC_logic + ) + +# Links the core alone, not MeshMC_logic: the palette is core and this proves it. +add_unit_test(ThemePalette + SOURCES theme/ThemePalette_test.cpp + LIBS MeshMC_core + ) + +add_unit_test(InstanceFilterModel + SOURCES models/InstanceFilterModel_test.cpp + LIBS MeshMC_core + ) + +# Mostly exercises InstanceList::newestScreenshotUrl() directly (see +# InstanceList_test.cpp) - a full InstanceList needs a SettingsObjectPtr and +# real BaseInstance subclasses, which is more than that one lookup needs to +# be tested. HasCrashedRole has no such helper, so one test builds that +# fixture (InstanceList + NullInstance) instead. +add_unit_test(InstanceList + SOURCES InstanceList_test.cpp + LIBS MeshMC_core + ) + +add_unit_test(LaunchProgressTracker + SOURCES launch/LaunchProgressTracker_test.cpp + LIBS MeshMC_core + ) + +add_unit_test(IdSelectionModel + SOURCES models/IdSelectionModel_test.cpp + LIBS MeshMC_core + ) + +add_unit_test(SettingsAdapter + SOURCES models/SettingsAdapter_test.cpp + LIBS MeshMC_core + ) + +# Exercises only InstanceLogBridge: a full MinecraftInstance (needed for +# InstanceDetails' mods/worlds/components) is impractical to construct here, +# but InstanceLogBridge only needs a BaseInstance and a LaunchTask, and +# NullInstance + LaunchTask::create() are both concrete enough to drive by +# hand. +add_unit_test(InstanceLogBridge + SOURCES models/InstanceDetails_test.cpp + LIBS MeshMC_core + ) + +# Covers what is testable without a LauncherContext or network: AccountList's +# QML roles (including the ones added for this page) and the parts of +# AccountsController that only touch AccountList/MinecraftAccount -- +# add/remove/default and offline validation. loginMicrosoft() itself needs +# LAUNCHER->network() (via MSAStep) and is not exercised here. +add_unit_test(AccountsController + SOURCES models/AccountsController_test.cpp + LIBS MeshMC_core + ) + +# Covers what is testable without a LauncherContext or network: the two +# Meta version-list proxies (filtering, sorting, loading/error) against a +# fake BaseVersionList, and the two free-standing suggested-name helpers +# (create mode's composeSuggestedInstanceName(), import mode's +# suggestedImportName()). NewInstanceController's own create()/importFrom() +# need LAUNCHER->metadataIndex()/instances() and are not exercised here. +add_unit_test(NewInstanceController + SOURCES models/NewInstanceController_test.cpp + LIBS MeshMC_core + ) + +# Needs neither a BaseInstance nor a LauncherContext - only a directory to +# read/write servers.dat under, the same reason OtherLogsModel_test.cpp +# drives that model directly against a temp directory. BackupController and +# WorldDataPacksController (the other new InstanceDetails-owned models this +# phase added) both need a real MinecraftInstance/BaseInstance and are not +# exercised here, the same reason models/InstanceDetails_test.cpp only +# exercises InstanceLogBridge. +add_unit_test(ServersListModel + SOURCES models/ServersListModel_test.cpp + LIBS MeshMC_core + ) + +# Exercises only providerFromString() - the one static, instance-free helper +# ManagedPackController has. isSupported()/fetchVersions()/updateToVersion() +# all need a real BaseInstance and are not exercised here, the same reason +# models/ContentBrowser_test.cpp only exercises its own static helpers. +add_unit_test(ManagedPackController + SOURCES models/ManagedPackController_test.cpp + LIBS MeshMC_core + ) + +# Exercises RecentWorldsModel::scan() - the static, pure worker a rescan +# hands to QtConcurrent::run() - directly against a temporary directory, plus +# the async rescan-trigger/debounce orchestration around it against a real +# InstanceList (NullInstance fixture, same as InstanceList_test.cpp's +# HasCrashedRole tests) - that needs no real MinecraftInstance either. +add_unit_test(RecentWorldsModel + SOURCES models/RecentWorldsModel_test.cpp + LIBS MeshMC_core + ) + +# A trivial Task subclass drives status/progress/success/failure by hand; +# no LauncherContext or network needed. +add_unit_test(TaskWatcher + SOURCES tasks/TaskWatcher_test.cpp + LIBS MeshMC_core + ) + +# Exercises only the static JSON parsing helpers (canned search/version +# replies), not the model itself - see the class comment on +# ModrinthModpackModel::parseSearchResults(). +add_unit_test(ModrinthModpackModel + SOURCES modplatform/modrinth/ModrinthModpackModel_test.cpp + LIBS MeshMC_core + ) + +# Exercises only ContentBrowser's static, network-free helpers +# (isVersionCompatible(), sortOptionsFor()) - constructing a full +# MinecraftInstance to drive the browser itself is impractical here, the +# same reason models/InstanceDetails_test.cpp only exercises +# InstanceLogBridge. +add_unit_test(ContentBrowser + SOURCES models/ContentBrowser_test.cpp + LIBS MeshMC_core + ) + +# install() itself needs a PackProfile, which needs a MinecraftInstance - +# impractical to construct here, the same reason ContentBrowser_test.cpp +# above only exercises its static helpers. installSequence() is the one +# static helper LoaderInstaller has: the pure ordering install() drives +# itself by - see its own comment for why that order is worth a test on +# its own. OtherLogsModel needs neither a profile nor an instance - just a +# path and a filter - so it gets a full test against a temp directory +# instead. +add_unit_test(LoaderInstaller + SOURCES models/LoaderInstaller_test.cpp + LIBS MeshMC_core + ) + +add_unit_test(OtherLogsModel + SOURCES models/OtherLogsModel_test.cpp + LIBS MeshMC_core + ) + +# Exercised against a real GameOptions (a temp options.txt, no instance +# needed - see GameOptions' own constructor) rather than a fake model: +# nothing here is specific to GameOptions, but it is the one real source +# this filter is built for. +add_unit_test(KeyValueFilterModel + SOURCES models/KeyValueFilterModel_test.cpp + LIBS MeshMC_core + ) + add_unit_test(IconTheme SOURCES ui/themes/IconTheme_test.cpp LIBS MeshMC_logic ) +# Model bookkeeping only (roleNames(), builtin vs. file-based) -- no +# compiled icon theme resource needed, unlike IconTheme/InstanceIconProvider, +# since addThemeIcon()/addIcon() never render anything. +add_unit_test(IconList + SOURCES icons/IconList_test.cpp + LIBS MeshMC_core Qt${QT_VERSION_MAJOR}::Gui + ) + target_compile_definitions(IconTheme_test PRIVATE MESHMC_MAINWINDOW_UI_PATH="${CMAKE_CURRENT_SOURCE_DIR}/ui/MainWindow.ui") @@ -1216,7 +1491,13 @@ set_tests_properties(IconTheme PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen;QT_QPA_PLATFORMTHEME=" ) -target_link_libraries(MeshMC_logic +# Core headers are included by their path under launcher/ ("core/UiHost.h", +# "InstanceList.h"), so that directory is part of the core's interface. Stated +# here rather than left to CMAKE_INCLUDE_CURRENT_DIR, which only covers targets +# declared in this directory -- the QML module is declared in launcher/qml/. +target_include_directories(MeshMC_core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) + +target_link_libraries(MeshMC_core PUBLIC classparser nbt++ ZLIB::ZLIB @@ -1226,7 +1507,7 @@ target_link_libraries(MeshMC_logic ) if(MeshMC_DISABLE_JAVA_DOWNLOADER) - target_compile_definitions(MeshMC_logic PUBLIC MeshMC_DISABLE_JAVA_DOWNLOADER) + target_compile_definitions(MeshMC_core PUBLIC MeshMC_DISABLE_JAVA_DOWNLOADER) endif() # Keep the function/line of every log message in *all* configurations. Qt only @@ -1237,21 +1518,31 @@ endif() # ones in the application target. # Cost, knowingly accepted: __FILE__ is baked into the binary for each # translation unit that logs, so the binary grows and carries build paths. -target_compile_definitions(MeshMC_logic PUBLIC QT_MESSAGELOGCONTEXT) -target_link_libraries(MeshMC_logic +target_compile_definitions(MeshMC_core PUBLIC QT_MESSAGELOGCONTEXT) +target_link_libraries(MeshMC_core PUBLIC Qt${QT_VERSION_MAJOR}::Core Qt${QT_VERSION_MAJOR}::Xml Qt${QT_VERSION_MAJOR}::Network Qt${QT_VERSION_MAJOR}::NetworkAuth Qt${QT_VERSION_MAJOR}::Concurrent Qt${QT_VERSION_MAJOR}::Gui - Qt${QT_VERSION_MAJOR}::Widgets LibArchive::LibArchive cmark::cmark rainbow LocalPeer::LocalPeer ) +# QtWidgets is linked here and only here. See MeshMC_core_link_check below for +# how that is enforced rather than merely intended. +target_link_libraries(MeshMC_logic Qt${QT_VERSION_MAJOR}::Widgets) + +# Application decides at startup whether to show MainWindow or the QML shell, +# so it needs the shell compiled in either way. See useQmlShell(). +target_link_libraries(MeshMC_logic MeshMC_qml) +if(MeshMC_QML_UI) + target_compile_definitions(MeshMC_logic PRIVATE MESHMC_QML_UI_DEFAULT) +endif() + target_link_libraries(MeshMC_logic Qt6::OpenGL) # Plugin system needs dlopen/dlsym on Unix @@ -1274,7 +1565,9 @@ else() endif() if(APPLE) - target_link_libraries(MeshMC_logic + # PUBLIC on the core: MacSparkleUpdater.mm is core and imports Cocoa, and + # ThemeManager.mm in MeshMC_logic inherits it from there. + target_link_libraries(MeshMC_core PUBLIC "-framework AppKit" "-framework Foundation" objc @@ -1287,9 +1580,9 @@ if(MeshMC_SPARKLE_ENABLED) # PUBLIC: Application.cpp decides which updater to construct on the # strength of this define, and it is compiled into the application target # as well as into the logic library. - target_compile_definitions(MeshMC_logic PUBLIC MESHMC_SPARKLE_ENABLED) - target_include_directories(MeshMC_logic PRIVATE "${MACOSX_SPARKLE_DIR}") - target_link_libraries(MeshMC_logic ${SPARKLE_FRAMEWORK}) + target_compile_definitions(MeshMC_core PUBLIC MESHMC_SPARKLE_ENABLED) + target_include_directories(MeshMC_core PRIVATE "${MACOSX_SPARKLE_DIR}") + target_link_libraries(MeshMC_core PUBLIC ${SPARKLE_FRAMEWORK}) # Sparkle has to ship inside the bundle: it is loaded at runtime, and it # is what checks the signature of the update it installs. @@ -1298,6 +1591,28 @@ if(MeshMC_SPARKLE_ENABLED) USE_SOURCE_PERMISSIONS) endif() +######## The core must link without QtWidgets ######## + +# Not linking Qt::Widgets into MeshMC_core is not enough on its own to keep +# widgets out of it. A static library is never linked by itself -- unresolved +# symbols are left for the final executable, which does link QtWidgets through +# MeshMC_logic -- and on macOS all Qt frameworks sit in one directory, so +# #include resolves through the framework search path even +# without the target. Either way a widget dependency would slip in unnoticed. +# +# So link the core, whole, into a program that has nothing else. Every object +# in the archive is pulled in whether or not anything calls it, and the program +# is given nothing that could satisfy a QtWidgets symbol. If any core object +# needs one, this target fails to link and the build breaks, on every platform, +# in CI and locally alike. It is part of `all` on purpose. +add_executable(MeshMC_core_link_check core/CoreLinkCheck.cpp) +target_link_libraries(MeshMC_core_link_check PRIVATE + "$") + +# Listed as a test as well, so the invariant has a name in the ctest output +# and is not only visible as a link step when it fails. +add_test(NAME CoreLinksWithoutQtWidgets COMMAND MeshMC_core_link_check) + ######## QML user interface module ######## # Defined in its own directory so the module's resource aliases stay clean; see @@ -1310,9 +1625,14 @@ add_subdirectory(qml) # Proves the QML toolchain end to end: module built, resources at the expected # prefix, engine can instantiate the root component. All three fail silently # otherwise -- a green build and an application that renders nothing. +# MeshMC_qml is linked explicitly: it carries the compiled .qml resources and +# puts the module plugins in the link closure, which is where +# qt_import_qml_plugins looks for them. Relying on qmlimportscanner to find +# them from the test's sources alone worked on macOS but left Main.qml out of +# the resources on Linux. add_unit_test(QmlModule SOURCES qml/QmlModule_test.cpp - LIBS Qt${QT_VERSION_MAJOR}::Quick Qt${QT_VERSION_MAJOR}::Qml Qt${QT_VERSION_MAJOR}::Gui + LIBS MeshMC_qml Qt${QT_VERSION_MAJOR}::Quick Qt${QT_VERSION_MAJOR}::Qml Qt${QT_VERSION_MAJOR}::Gui ) # qt_add_qml_module builds the module as a static library plus a separate QML @@ -1322,9 +1642,58 @@ add_unit_test(QmlModule # links and then fails at runtime with 'plugin "MeshMC_qmlplugin" not found'. qt_import_qml_plugins(QmlModule_test) +# MeshMC_logic rather than the core: the built-in icons are compiled from +# multimc.qrc into MeshMC_logic, and a test where every key falls back to the +# default icon would prove nothing. +add_unit_test(InstanceIconProvider + SOURCES qml/InstanceIconProvider_test.cpp + LIBS MeshMC_logic Qt${QT_VERSION_MAJOR}::Quick Qt${QT_VERSION_MAJOR}::Gui + ) + +# Exercises only AccountFaceProvider::faceFromSkin(), the pure compositing +# helper -- requestPixmap() itself needs a real LauncherContext/AccountList, +# which is neither cheap nor hermetic to fake here. MeshMC_logic (rather than +# just MeshMC_core) because AccountFaceProvider.cpp lives in MeshMC_qml, +# which MeshMC_logic links. +add_unit_test(AccountFaceProvider + SOURCES qml/AccountFaceProvider_test.cpp + LIBS MeshMC_logic Qt${QT_VERSION_MAJOR}::Quick Qt${QT_VERSION_MAJOR}::Gui + ) + +add_unit_test(ScreenshotThumbnailProvider + SOURCES qml/ScreenshotThumbnailProvider_test.cpp + LIBS MeshMC_logic Qt${QT_VERSION_MAJOR}::Quick Qt${QT_VERSION_MAJOR}::Gui + ) + +# Exercises only sanitizedInstanceName(), the free-standing helper +# renameInstance()/duplicateInstance() build on -- the rest of QmlShell needs +# a real LauncherContext, same reason NewInstanceController_test.cpp only +# covers composeSuggestedInstanceName(). MeshMC_logic because QmlShell.cpp +# lives in MeshMC_qml, which MeshMC_logic links. +add_unit_test(QmlShell + SOURCES qml/QmlShell_test.cpp + LIBS MeshMC_logic Qt${QT_VERSION_MAJOR}::Quick Qt${QT_VERSION_MAJOR}::Gui + ) + +# Drives QmlUiHost headlessly: answers a pending request from a queued +# QTimer the way QML would, and checks cancellation (quit and this object's +# own destruction) and busy nesting. MeshMC_logic because QmlUiHost.cpp +# lives in MeshMC_qml, which MeshMC_logic links. +add_unit_test(QmlUiHost + SOURCES qml/QmlUiHost_test.cpp + LIBS MeshMC_logic Qt${QT_VERSION_MAJOR}::Quick Qt${QT_VERSION_MAJOR}::Gui + ) + add_executable(${MeshMC_Name} MACOSX_BUNDLE WIN32 main.cpp ${MESHMC_RCS}) -target_link_libraries(${MeshMC_Name} MeshMC_logic) -target_link_libraries(${MeshMC_Name} MeshMC_qml) +# Keyword signature: qt_import_qml_plugins below links with PRIVATE, and CMake +# refuses to mix the two forms on one target. +target_link_libraries(${MeshMC_Name} PRIVATE MeshMC_logic) + +# Same reason as QmlModule_test: MeshMC_qml is a static QML module with a +# separate plugin, and a plain add_executable does not import it. Without this +# the launcher links and then fails at runtime with 'plugin "MeshMC_qmlplugin" +# not found' the moment the QML shell loads. +qt_import_qml_plugins(${MeshMC_Name}) # Export all symbols so dlopen()'ed .mmco plugins can resolve launcher symbols at runtime set_target_properties(${MeshMC_Name} PROPERTIES ENABLE_EXPORTS ON) @@ -1599,10 +1968,19 @@ if(WIN32 OR (UNIX AND APPLE)) # Understood by both Qt 5 and Qt 6 windeployqt. # install-qt-action and MSYS2 only provide release Qt, so always use # --release even when the app itself is built in Debug mode. + # + # --qmldir points windeployqt at our QML sources so qmlimportscanner + # can see what our own qt_add_qml_module() modules import (MeshMC, + # MeshMC.Theme, MeshMC.Style and MeshMC.Components are compiled into + # meshmc.exe as static QML modules, but the Qt-provided modules they + # import -- QtQuick, QtQuick.Controls, QtQml, ... -- are not, and + # still have to be deployed as plugins). --no-quick-import used to be + # passed here, which skipped QML deployment entirely and left the + # QML shell unable to find QtQuick.Controls at runtime. set(WINDEPLOYQT_OPTIONS --release --no-opengl-sw - --no-quick-import + --qmldir "${CMAKE_CURRENT_SOURCE_DIR}/qml" --no-system-d3d-compiler --no-translations --no-compiler-runtime @@ -1648,9 +2026,157 @@ if(WIN32 OR (UNIX AND APPLE)) endif() if(UNIX AND APPLE) + # qt_deploy_runtime_dependencies() below only follows *linked* + # libraries and Qt's regular (dlopen()'d-by-plugin-type) plugins; it + # does not know about QML modules, which are dlopen()'d by URI at + # run time. Our own modules (MeshMC, MeshMC.Theme, MeshMC.Style, + # MeshMC.Components) are compiled into meshmc itself via + # qt_import_qml_plugins() and need nothing here. The modules those + # import from Qt -- QtQuick, QtQuick.Controls, QtQml, ... -- are not + # compiled in and are otherwise entirely missing from the bundle. + # + # Deploy exactly the Qt QML modules launcher/qml/ imports, found with + # qmlimportscanner, rather than the whole of $QT_QML_DIR (which also + # contains Quick3D, WebEngine, positioning, etc. that this UI never + # touches). Done ahead of the qt_deploy_runtime_dependencies() call + # below so its ADDITIONAL_LIBRARIES can include these plugins: each + # one has its own dependencies (e.g. libqtquickcontrols2plugin.dylib + # needs QtQuickControls2Impl.framework) that nothing linked into + # meshmc itself pulls in, and qt_deploy_runtime_dependencies is the + # tool that already knows how to resolve and copy those correctly + # (whole .framework bundles, symlink chains and all) rather than + # reimplementing that here. + find_program(QMLIMPORTSCANNER_EXECUTABLE + NAMES qmlimportscanner + HINTS + "${QT_LIBEXECS_DIR}" + "${Qt${QT_VERSION_MAJOR}_DIR}/../../../bin" + "${Qt${QT_VERSION_MAJOR}_DIR}/../../../libexec" + ) + if(NOT QMLIMPORTSCANNER_EXECUTABLE) + message(FATAL_ERROR + "qmlimportscanner not found (searched QT_LIBEXECS_DIR=" + "${QT_LIBEXECS_DIR} and Qt${QT_VERSION_MAJOR}_DIR=" + "${Qt${QT_VERSION_MAJOR}_DIR}/../..). It ships with Qt's " + "qtdeclarative tools and is required to know which Qt QML " + "modules to bundle into ${MeshMC_Name}.app.") + endif() + + execute_process( + COMMAND "${QMLIMPORTSCANNER_EXECUTABLE}" + -rootPath "${CMAKE_CURRENT_SOURCE_DIR}/qml" + -importPath "${QT_QML_DIR}" + OUTPUT_VARIABLE _qml_scan_json + RESULT_VARIABLE _qml_scan_result + ) + if(NOT _qml_scan_result EQUAL 0) + message(FATAL_ERROR + "qmlimportscanner failed while scanning launcher/qml " + "(exit code: ${_qml_scan_result})") + endif() + + # Pull "relativePath" out of the scanner's JSON by hand rather than + # depending on CMake 3.19's string(JSON ...), which the floor here + # does not guarantee. Modules the scanner could not resolve on disk + # (our own MeshMC.* modules, looked up only via -importPath) have no + # "relativePath" key at all, so they never match and are correctly + # left out -- they are compiled in, not deployed. + string(REGEX MATCHALL "\"relativePath\": *\"[^\"]*\"" + _qml_relpath_matches "${_qml_scan_json}") + set(MESHMC_QT_QML_MODULES "") + foreach(_qml_match IN LISTS _qml_relpath_matches) + string(REGEX REPLACE "\"relativePath\": *\"([^\"]*)\"" "\\1" + _qml_relpath "${_qml_match}") + list(APPEND MESHMC_QT_QML_MODULES "${_qml_relpath}") + endforeach() + list(REMOVE_DUPLICATES MESHMC_QT_QML_MODULES) + + if(NOT MESHMC_QT_QML_MODULES) + message(FATAL_ERROR + "qmlimportscanner reported no deployable Qt QML modules for " + "launcher/qml. qt.conf's QmlImports would point at an empty " + "directory and the QML shell would fail to load QtQuick at " + "startup, so this is a hard error rather than a warning.") + endif() + message(STATUS "Qt QML modules to bundle into ${MeshMC_Name}.app: ${MESHMC_QT_QML_MODULES}") + + # Destination-relative path of each module's plugin binary (if any), + # once copied below -- NOT the original file under $QT_QML_DIR. + # ADDITIONAL_LIBRARIES further down is resolved by + # file(GET_RUNTIME_DEPENDENCIES) against the *installed* copy (a + # relative path there is looked up under the deploy tool's own + # runtime install prefix, same as EXECUTABLE above); pointing it at + # $QT_QML_DIR instead would have it run install_name_tool/codesign + # machinery against files in the Qt installation itself -- rewriting + # a shared, developer-machine-wide Qt install as a side effect of + # packaging our own app is not something a build should ever do. + set(MESHMC_QT_QML_PLUGIN_FILES "") + foreach(_qml_module IN LISTS MESHMC_QT_QML_MODULES) + file(GLOB _qml_module_plugin_libs "${QT_QML_DIR}/${_qml_module}/*.dylib") + foreach(_qml_module_plugin_lib IN LISTS _qml_module_plugin_libs) + get_filename_component(_qml_plugin_name "${_qml_module_plugin_lib}" NAME) + list(APPEND MESHMC_QT_QML_PLUGIN_FILES "${QML_DEST_DIR}/${_qml_module}/${_qml_plugin_name}") + endforeach() + endforeach() + + # Pre-render as one quoted argument per line, same reason as + # WINDEPLOYQT_OPTIONS_CODE above: splicing the list directly into the + # CONTENT string would paste it in semicolon-separated. + set(MESHMC_QT_QML_PLUGIN_FILES_CODE "") + foreach(_qml_plugin_file IN LISTS MESHMC_QT_QML_PLUGIN_FILES) + string(APPEND MESHMC_QT_QML_PLUGIN_FILES_CODE "\n \"${_qml_plugin_file}\"") + endforeach() + + foreach(_qml_module IN LISTS MESHMC_QT_QML_MODULES) + # Copy only the files ${_qml_module} owns directly (its qmldir, + # plugin binary, any local .qml/.qmltypes) -- not recursively. + # $QT_QML_DIR nests *other*, unrelated QML modules as + # subdirectories of shared parents (QtQuick/ alone also holds + # WebEngine's Pdf/, Qt3D's Scene2D and Scene3D, VirtualKeyboard, + # Timeline, ...), and a plain recursive install(DIRECTORY) would + # have pulled all of that in too. Any subdirectory that genuinely + # is a module we need is already its own separate entry here + # (qmlimportscanner reports e.g. "QtQuick/Controls/Basic/impl" + # alongside "QtQuick/Controls/Basic"), so nothing needs to + # recurse by hand. FOLLOW_SYMLINK_CHAIN matters on installations + # (e.g. Homebrew's, where every file here is itself a symlink + # into a completely different keg directory) where a plain copy + # would reproduce a dangling symlink instead of the real file. + # + # Runs ahead of the qt_deploy_runtime_dependencies() script below + # (registered via install(SCRIPT), further down) so that by the + # time it inspects ${MESHMC_QT_QML_PLUGIN_FILES}, those files + # already exist at their *installed* location -- install rules + # run in registration order within a directory. + install(CODE " + file(GLOB _qml_module_files LIST_DIRECTORIES false + \"${QT_QML_DIR}/${_qml_module}/*\") + if(_qml_module_files) + file(INSTALL \${_qml_module_files} + DESTINATION \"\${CMAKE_INSTALL_PREFIX}/${QML_DEST_DIR}/${_qml_module}\" + FOLLOW_SYMLINK_CHAIN + ) + endif() + ") + endforeach() + # qt_generate_deploy_script/qt_deploy_runtime_dependencies need Qt 6.3+, # which the 6.4 floor guarantees. This replaced a macdeployqt shell-out # that only existed for the Qt 5 build. + # + # ADDITIONAL_LIBRARIES: qt_deploy_runtime_dependencies() above only + # follows *linked* libraries and Qt's regular + # (dlopen()'d-by-plugin-type) plugins; it does not know about QML + # modules, which are dlopen()'d by URI at run time. Our own modules + # (MeshMC, MeshMC.Theme, MeshMC.Style, MeshMC.Components) are + # compiled into meshmc itself via qt_import_qml_plugins() and need + # nothing here. The Qt QML plugins copied in just above are not + # compiled in, though, and have dependencies of their own (e.g. + # libqtquickcontrols2plugin.dylib needs QtQuickControls2Impl.framework) + # that nothing linked into meshmc itself pulls in -- ADDITIONAL_LIBRARIES + # is what makes qt_deploy_runtime_dependencies also resolve and copy + # those (whole .framework bundles, symlink chains and all) rather + # than reimplementing that here. qt_generate_deploy_script( TARGET ${MeshMC_Name} OUTPUT_SCRIPT QT_DEPLOY_SCRIPT @@ -1661,6 +2187,7 @@ if(WIN32 OR (UNIX AND APPLE)) LIBEXEC_DIR ${BINARY_DEST_DIR} LIB_DIR ${LIBRARY_DEST_DIR} PLUGINS_DIR ${PLUGIN_DEST_DIR} + ADDITIONAL_LIBRARIES${MESHMC_QT_QML_PLUGIN_FILES_CODE} NO_OVERWRITE NO_TRANSLATIONS NO_COMPILER_RUNTIME @@ -1669,6 +2196,36 @@ if(WIN32 OR (UNIX AND APPLE)) install( SCRIPT ${QT_DEPLOY_SCRIPT} ) + + foreach(_qml_module IN LISTS MESHMC_QT_QML_MODULES) + # Each plugin .dylib in ${_qml_module} carries an @loader_path + # (or @rpath) reference to sibling Qt frameworks, resolved + # relative to *its original location* inside the Qt install + # ($QT_QML_DIR/${_qml_module}, some fixed number of directories + # above $QT_LIBS_DIR). That relationship does not hold once the + # plugin is copied into Contents/Resources/qml/... -- Frameworks + # sits somewhere else entirely. install_name_tool -add_rpath + # points it at the real Frameworks directory too; this adds a + # working rpath rather than replacing the existing (now + # irrelevant) one, so it is harmless if the plugin also has some + # already-working entry, and safe to re-run against an existing + # install (a duplicate -add_rpath just fails and is ignored). + file(RELATIVE_PATH _qml_module_to_frameworks + "${CMAKE_INSTALL_PREFIX}/${QML_DEST_DIR}/${_qml_module}" + "${CMAKE_INSTALL_PREFIX}/${FRAMEWORK_DEST_DIR}") + install(CODE " + file(GLOB _qml_plugin_libs + \"\${CMAKE_INSTALL_PREFIX}/${QML_DEST_DIR}/${_qml_module}/*.dylib\") + foreach(_qml_plugin_lib IN LISTS _qml_plugin_libs) + execute_process( + COMMAND install_name_tool + -add_rpath \"@loader_path/${_qml_module_to_frameworks}\" + \"\${_qml_plugin_lib}\" + OUTPUT_QUIET ERROR_QUIET + ) + endforeach() + ") + endforeach() endif() # Bundle our linked dependencies @@ -1793,8 +2350,18 @@ if(WIN32 OR (UNIX AND APPLE)) endif() # Add qt.conf - tell Qt where to find plugins relative to the executable + # + # QmlImports is the Qt 6 key for this (Qt 6 ignores the older Qt 5 + # Qml2Imports key); it has to be set explicitly here, same as Plugins, + # because nothing else on Windows/macOS writes a qt.conf for this install. + # QT_CONF_PLUGINS_DEST/QT_CONF_QML_DEST (set in the top-level + # CMakeLists.txt, next to PLUGIN_DEST_DIR/QML_DEST_DIR) are where + # windeployqt (Windows) / the QML deploy step above (macOS) actually put + # Qt's plugins and QML modules, expressed the way qt.conf expects them on + # each platform -- which is *not* the same as the install()-DESTINATION + # form PLUGIN_DEST_DIR/QML_DEST_DIR use elsewhere in this file. install( - CODE "file(WRITE \"\${CMAKE_INSTALL_PREFIX}/${RESOURCES_DEST_DIR}/qt.conf\" \"[Paths]\nPlugins = ${PLUGIN_DEST_DIR}\n\")" + CODE "file(WRITE \"\${CMAKE_INSTALL_PREFIX}/${RESOURCES_DEST_DIR}/qt.conf\" \"[Paths]\nPlugins = ${QT_CONF_PLUGINS_DEST}\nQmlImports = ${QT_CONF_QML_DEST}\n\")" ) # Add qtlogging.ini as a config file install( diff --git a/launcher/InstanceCreationTask.cpp b/launcher/InstanceCreationTask.cpp index ceb39932..f760cb3b 100644 --- a/launcher/InstanceCreationTask.cpp +++ b/launcher/InstanceCreationTask.cpp @@ -25,9 +25,13 @@ #include "minecraft/MinecraftInstance.h" #include "minecraft/PackProfile.h" -InstanceCreationTask::InstanceCreationTask(BaseVersionPtr version) +InstanceCreationTask::InstanceCreationTask(BaseVersionPtr version, + const QString& loaderUid, + const QString& loaderVersion) { m_version = version; + m_loaderUid = loaderUid; + m_loaderVersion = loaderVersion; } void InstanceCreationTask::executeTask() @@ -53,6 +57,14 @@ void InstanceCreationTask::executeTask() components->buildingFromScratch(); components->setComponentVersion("net.minecraft", m_version->descriptor(), true); + if (!m_loaderUid.isEmpty()) { + /* Same call InstallLoaderDialog::applySelection() makes + * (ui/dialogs/InstallLoaderDialog.cpp) - not marked + * "important", so the component is exactly as removable and + * disableable as one installed after the fact would be. */ + components->setComponentVersion(m_loaderUid, m_loaderVersion); + components->resolve(Net::Mode::Online); + } inst.setName(m_instName); inst.setIconKey(m_instIcon); instanceSettings->resumeSave(); diff --git a/launcher/InstanceCreationTask.h b/launcher/InstanceCreationTask.h index 41ad4247..37f97f5c 100644 --- a/launcher/InstanceCreationTask.h +++ b/launcher/InstanceCreationTask.h @@ -30,7 +30,15 @@ class InstanceCreationTask : public InstanceTask { Q_OBJECT public: - explicit InstanceCreationTask(BaseVersionPtr version); + /* @p loaderUid/@p loaderVersion name a mod loader component to install + * alongside net.minecraft, for a caller that lets someone pick a loader + * at creation time (the widget's NewInstanceDialog + VanillaPage do + * not; the QML "New instance" flow does - see + * models/NewInstanceController.cpp). Leave both empty for a plain + * vanilla instance. */ + explicit InstanceCreationTask(BaseVersionPtr version, + const QString& loaderUid = QString(), + const QString& loaderVersion = QString()); protected: //! Entry point for tasks. @@ -38,4 +46,6 @@ class InstanceCreationTask : public InstanceTask private: /* data */ BaseVersionPtr m_version; + QString m_loaderUid; + QString m_loaderVersion; }; diff --git a/launcher/InstanceImportTask.cpp b/launcher/InstanceImportTask.cpp index ccb9ecd4..3139dc01 100644 --- a/launcher/InstanceImportTask.cpp +++ b/launcher/InstanceImportTask.cpp @@ -21,7 +21,8 @@ #include "InstanceImportTask.h" #include "BaseInstance.h" #include "FileSystem.h" -#include "Application.h" +#include "core/LauncherContext.h" +#include "core/UiHost.h" #include "InstanceList.h" #include "MMCZip.h" #include "archive/ExtractZipTask.h" @@ -43,14 +44,9 @@ #include "modplatform/technic/TechnicPackProcessor.h" #include "icons/IconList.h" -#include "Application.h" #include "modplatform/flame/FlameApi.h" #include "modplatform/modrinth/ModrinthApi.h" -#include "ui/dialogs/BlockedModsDialog.h" -#include "ui/dialogs/CustomMessageBox.h" -#include "ui/dialogs/UntrustedModsDialog.h" -#include #include #include #include @@ -75,11 +71,11 @@ void InstanceImportTask::executeTask() m_downloadRequired = true; const QString path = m_sourceUrl.host() + '/' + m_sourceUrl.path(); - auto entry = APPLICATION->metacache()->resolveEntry("general", path); + auto entry = LAUNCHER->metacache()->resolveEntry("general", path); entry->setStale(true); m_archiveEntry = entry; m_filesNetJob = - new NetJob(tr("Modpack download"), APPLICATION->network()); + new NetJob(tr("Modpack download"), LAUNCHER->network()); m_filesNetJob->addNetAction( Net::Download::makeCached(m_sourceUrl, entry)); m_archivePath = entry->getFullPath(); @@ -314,7 +310,7 @@ void InstanceImportTask::extractFailed() if (!QFile::remove(m_archivePath)) { qWarning() << "Could not remove" << m_archivePath; } - APPLICATION->metacache()->evictEntry(m_archiveEntry); + LAUNCHER->metacache()->evictEntry(m_archiveEntry); m_archiveEntry.reset(); emitFailed(tr("Failed to extract modpack. The downloaded archive is " "damaged; it has been discarded, so trying again will " @@ -637,7 +633,7 @@ void InstanceImportTask::processFlame() listFilesRelative(FS::PathCombine(m_stagingPath, gameDirName())); m_modIdResolver = - new Flame::FileResolvingTask(APPLICATION->network(), pack); + new Flame::FileResolvingTask(LAUNCHER->network(), pack); connect(m_modIdResolver.get(), &Flame::FileResolvingTask::succeeded, this, &InstanceImportTask::onFlameFileResolutionSucceeded); connect(m_modIdResolver.get(), &Flame::FileResolvingTask::failed, @@ -828,7 +824,7 @@ void InstanceImportTask::onFlameFileResolutionSucceeded() } } - m_filesNetJob = new NetJob(tr("Mod download"), APPLICATION->network()); + m_filesNetJob = new NetJob(tr("Mod download"), LAUNCHER->network()); // Collect restricted mods that need browser download QList blockedMods; @@ -853,7 +849,7 @@ void InstanceImportTask::onFlameFileResolutionSucceeded() QDir installedGameDir; bool canReuseInstalled = false; if (!m_updateTarget.isEmpty()) { - auto previous = APPLICATION->instances()->getInstanceById( + auto previous = LAUNCHER->instances()->getInstanceById( m_updateTarget.instanceId); if (auto minecraftPrevious = std::dynamic_pointer_cast(previous)) { @@ -977,16 +973,15 @@ void InstanceImportTask::onFlameFileResolutionSucceeded() // Handle restricted mods via dialog if (!blockedMods.isEmpty()) { - BlockedModsDialog dlg(nullptr, tr("Restricted Mods"), - tr("The following mods have restricted downloads " - "and are not available through the API.\n" - "Click the Download button next to each mod " - "to open its download page in your browser.\n" - "Once all files appear in your Downloads " - "folder, click Continue."), - blockedMods); - - if (dlg.exec() == QDialog::Accepted) { + if (LAUNCHER->uiHost()->resolveBlockedMods( + tr("Restricted Mods"), + tr("The following mods have restricted downloads " + "and are not available through the API.\n" + "Click the Download button next to each mod " + "to open its download page in your browser.\n" + "Once all files appear in your Downloads " + "folder, click Continue."), + blockedMods)) { QString downloadDir = QStandardPaths::writableLocation( QStandardPaths::DownloadLocation); for (const auto& mod : blockedMods) { @@ -1239,7 +1234,7 @@ void InstanceImportTask::processModrinth() // Download all mod files m_filesNetJob = - new NetJob(tr("Modrinth mod download"), APPLICATION->network()); + new NetJob(tr("Modrinth mod download"), LAUNCHER->network()); auto minecraftDir = FS::PathCombine(m_stagingPath, gameDirName()); auto canonicalBase = QDir(minecraftDir).canonicalPath(); /* What this version is responsible for. Built from the same loop that @@ -1260,7 +1255,7 @@ void InstanceImportTask::processModrinth() QDir installedGameDir; bool canReuseInstalled = false; if (!m_updateTarget.isEmpty()) { - auto previous = APPLICATION->instances()->getInstanceById( + auto previous = LAUNCHER->instances()->getInstanceById( m_updateTarget.instanceId); if (auto minecraftPrevious = std::dynamic_pointer_cast(previous)) { @@ -1477,7 +1472,7 @@ void InstanceImportTask::processMeshMC() IconUtils::findBestIconIn(instance.instanceRoot(), m_instIcon); if (!importIconPath.isNull() && QFile::exists(importIconPath)) { // import icon - auto iconList = APPLICATION->icons(); + auto iconList = LAUNCHER->icons(); if (iconList->iconFileExists(m_instIcon)) { iconList->deleteIcon(m_instIcon); } @@ -1634,10 +1629,9 @@ bool InstanceImportTask::confirmUntrustedFiles(const QStringList& suspectPaths) qWarning() << "Untrusted modpack carries" << suspectPaths.size() << "file(s) we cannot vouch for"; - /* A dialog of its own, with the files listed in it and consent as a - * separate deliberate act - see UntrustedModsDialog. */ - UntrustedModsDialog dialog(suspectPaths, m_dialogParent); - return dialog.exec() == QDialog::Accepted; + /* A surface of its own, with the files listed on it and consent as a + * separate deliberate act. */ + return LAUNCHER->uiHost()->confirmUntrustedMods(suspectPaths); } bool InstanceImportTask::resolveUpdateTargetFromCatalogue() @@ -1651,14 +1645,14 @@ bool InstanceImportTask::resolveUpdateTargetFromCatalogue() /* Not a catalogue install, so there is no id to match on. */ return true; } - if (APPLICATION->settings()->get("SkipModpackUpdatePrompt").toBool()) { + if (LAUNCHER->settings()->get("SkipModpackUpdatePrompt").toBool()) { /* Turned off, so installing means installing: a second instance, * without the question. Checked before looking anything up so * that the answer costs nothing when nobody wants it. */ return true; } - auto existing = APPLICATION->instances()->getInstanceByManagedPack( + auto existing = LAUNCHER->instances()->getInstanceByManagedPack( m_packHint.provider, m_packHint.packId); if (!existing) { return true; @@ -1670,8 +1664,10 @@ bool InstanceImportTask::resolveUpdateTargetFromCatalogue() ? QString() : tr(", at version %1").arg(installedVersion); - auto* box = CustomMessageBox::selectable( - m_dialogParent, tr("This modpack is already installed"), + /* Named actions rather than yes/no: there are three answers here and + * two of them install something. */ + const int choice = LAUNCHER->uiHost()->choose( + tr("This modpack is already installed"), tr("The instance \"%1\" was installed from this modpack%2.\n\n" "Updating it replaces the pack's own files and keeps everything " "that is yours: worlds, screenshots, play time and the " @@ -1682,18 +1678,10 @@ bool InstanceImportTask::resolveUpdateTargetFromCatalogue() "changes or removes mods can leave worlds made with the older " "version unusable.") .arg(existing->name(), versionSuffix), - QMessageBox::Question, QMessageBox::Cancel, QMessageBox::Cancel); - - /* Named actions rather than yes/no: there are three answers here and - * two of them install something. */ - auto* update = - box->addButton(tr("Update existing instance"), QMessageBox::AcceptRole); - auto* separate = - box->addButton(tr("Create separate instance"), QMessageBox::ResetRole); + UiHost::Severity::Question, + {tr("Update existing instance"), tr("Create separate instance")}); - box->exec(); - - if (box->clickedButton() == update) { + if (choice == 0) { /* The version fields are the catalogue entry the user picked - * the same thing the pack page would pass - because the instance * has to end up claiming the version it now actually has. */ @@ -1705,7 +1693,7 @@ bool InstanceImportTask::resolveUpdateTargetFromCatalogue() qDebug() << "Installing over existing instance" << target.instanceId; return true; } - if (box->clickedButton() == separate) { + if (choice == 1) { return true; } @@ -1731,7 +1719,7 @@ QString InstanceImportTask::gameDirName() } auto previous = - APPLICATION->instances()->getInstanceById(m_updateTarget.instanceId); + LAUNCHER->instances()->getInstanceById(m_updateTarget.instanceId); auto minecraftPrevious = std::dynamic_pointer_cast(previous); if (!minecraftPrevious) { @@ -1800,26 +1788,17 @@ static QString sidecarPathForModFile( * that cannot be downloaded again, and a pack that shipped one has no * way of knowing whether the copy on disk is still the one it shipped or * a hundred hours of somebody's game. */ -static bool askAboutDeletingSaves(QWidget* parent) +static bool askAboutDeletingSaves() { - auto* box = CustomMessageBox::selectable( - parent, QObject::tr("Delete existing save files"), + return LAUNCHER->uiHost()->confirm( + QObject::tr("Delete existing save files"), QObject::tr("The installed version of this modpack came with save " "files that the new version no longer includes.\n\n" "Would you like to remove them as part of this update? " "Keeping them is safe - they simply stay where they " "are, along with any progress made in them."), - QMessageBox::Question, QMessageBox::Yes | QMessageBox::No, - QMessageBox::No); - - if (auto* remove = box->button(QMessageBox::Yes)) { - remove->setText(QObject::tr("Remove saves")); - } - if (auto* keep = box->button(QMessageBox::No)) { - keep->setText(QObject::tr("Keep saves")); - } - - return box->exec() == QMessageBox::Yes; + UiHost::Severity::Question, QObject::tr("Remove saves"), + QObject::tr("Keep saves")); } bool InstanceImportTask::recordPackContents( @@ -1842,7 +1821,7 @@ bool InstanceImportTask::recordPackContents( } auto previous = - APPLICATION->instances()->getInstanceById(m_updateTarget.instanceId); + LAUNCHER->instances()->getInstanceById(m_updateTarget.instanceId); if (!previous) { /* Gone between the page opening and the update running. The * commit step will have its own opinion about that; there is @@ -1870,8 +1849,8 @@ bool InstanceImportTask::recordPackContents( * the update goes ahead without cleaning up, and says so - the * leftovers are visible to the user as duplicated mods, and being * surprised by that is worse than being told. */ - auto* box = CustomMessageBox::selectable( - m_dialogParent, tr("No file list for the installed version"), + return LAUNCHER->uiHost()->confirm( + tr("No file list for the installed version"), tr("The launcher has no record of which files the installed " "version of this modpack put into this instance, so it " "cannot remove the ones the new version no longer " @@ -1882,9 +1861,7 @@ bool InstanceImportTask::recordPackContents( "Instances installed before the launcher started keeping " "that record have no list. This update writes one, so the " "update after it will be able to clean up."), - QMessageBox::Warning, QMessageBox::Ok | QMessageBox::Cancel, - QMessageBox::Ok); - return box->exec() == QMessageBox::Ok; + UiHost::Severity::Warning, tr("Update anyway"), tr("Cancel")); } const QStringList stale = @@ -1917,7 +1894,7 @@ bool InstanceImportTask::recordPackContents( if (relativePath.startsWith(QLatin1String("saves/"), Qt::CaseInsensitive)) { if (m_savesDeletion == SavesDeletion::NotAsked) { - m_savesDeletion = askAboutDeletingSaves(m_dialogParent) + m_savesDeletion = askAboutDeletingSaves() ? SavesDeletion::Allowed : SavesDeletion::Refused; } @@ -1956,7 +1933,7 @@ void InstanceImportTask::carryOverUserSettings(BaseInstance& instance) } auto previous = - APPLICATION->instances()->getInstanceById(m_updateTarget.instanceId); + LAUNCHER->instances()->getInstanceById(m_updateTarget.instanceId); if (!previous) { /* The instance vanished between the page opening and the update * running. The staging step will fail to find it too; nothing diff --git a/launcher/InstanceImportTask.h b/launcher/InstanceImportTask.h index 13936e90..92ee77b1 100644 --- a/launcher/InstanceImportTask.h +++ b/launcher/InstanceImportTask.h @@ -110,11 +110,6 @@ class InstanceImportTask : public InstanceTask /* Parent for the dialogs the task may need to raise (confirmations, * warnings). Null is allowed and simply means the dialog is * parentless; it is not a reason to skip asking. */ - void setDialogParent(QWidget* parent) - { - m_dialogParent = parent; - } - /* Whether the archive came from somewhere we vouch for. * * A modpack is a list of code to execute. When the launcher itself @@ -248,7 +243,6 @@ class InstanceImportTask : public InstanceTask * this is opened - two live settings objects over one instance.cfg * means whichever writes last wins. */ std::shared_ptr m_gameFilesInstance; - QWidget* m_dialogParent = nullptr; bool m_trustedSource = false; /* Helper: persist the pack source hint into the freshly created diff --git a/launcher/InstanceList.cpp b/launcher/InstanceList.cpp index 60004461..0efeb77a 100644 --- a/launcher/InstanceList.cpp +++ b/launcher/InstanceList.cpp @@ -23,17 +23,22 @@ #include #include #include +#include +#include #include +#include #include #include #include #include #include +#include #include #include #include #include #include +#include #include #include "InstanceList.h" @@ -46,6 +51,10 @@ #include "FileSystem.h" #include "ExponentialSeries.h" #include "WatchLock.h" +#include "core/LauncherContext.h" +#include "icons/IconList.h" +#include "launch/LaunchProgressTracker.h" +#include "launch/LaunchTask.h" const static int GROUP_FILE_FORMAT_VERSION = 1; @@ -183,7 +192,28 @@ QString InstanceList::rootForStaging(const QString& stagingPath) const return QString(); } -InstanceList::~InstanceList() {} +InstanceList::~InstanceList() +{ + /* Drops the last InstancePtr reference to each instance (ordinarily + * held only here) while every member below is still fully alive. + * + * Without this, member destruction order does it instead: m_instances + * is declared near the top of the class, so - being torn down in + * reverse declaration order - it is one of the LAST members destroyed, + * well after m_launchTrackers. Destroying an instance emits + * QObject::destroyed(), which trackLaunchProgress() connects to a + * lambda that touches m_launchTrackers; with the instances outliving + * it, that lambda runs against a QHash that has already been torn + * down, and Qt's own QHash asserts (or worse, silently corrupts + * memory in a release build). Clearing explicitly here, before any + * member destructor has run, sidesteps the ordering question + * entirely. Never hit in the shipped app - InstanceList is a + * LauncherContext-owned singleton that outlives every Application + * shutdown path, which calls _exit() before C++ destructors run (see + * main.cpp) - but very much hit by a unit test that constructs one on + * the stack. */ + m_instances.clear(); +} Qt::DropActions InstanceList::supportedDragActions() const { @@ -287,7 +317,50 @@ QVariant InstanceList::data(const QModelIndex& index, int role) const return pdata->lastLaunch(); } case TotalTimePlayedRole: { - return pdata->totalTimePlayed(); + // int64_t is long on LP64 Linux, which QVariant has no + // constructor for; qint64 (long long) it has. + return static_cast(pdata->totalTimePlayed()); + } + case GameVersionRole: { + return pdata->gameVersion(); + } + case LoaderRole: + // Empty for vanilla: the UI words that itself, so it can tell + // "no loader" apart from a loader's name in any language. + return pdata->modLoaderName(); + case IconTintRole: { + auto* context = LauncherContext::instance(); + return context ? context->icons()->tint(pdata->iconKey()) + : QColor(); + } + case LaunchStatusRole: { + auto* tracker = m_launchTrackers.value(pdata, nullptr); + return tracker ? tracker->status() : QString(); + } + case LaunchProgressRole: { + auto* tracker = m_launchTrackers.value(pdata, nullptr); + return tracker ? tracker->progress() : -1.0; + } + case CoverImageRole: { + const InstanceId id = pdata->id(); + auto it = m_coverImageCache.constFind(id); + if (it != m_coverImageCache.constEnd()) { + return it.value(); + } + /* Not cached yet: kick off a background scan (scheduleCover- + * ImageScan() no-ops if one for this id is already running) and + * answer with nothing for now - the row picks up the real value + * once the scan's dataChanged arrives. data() is const, hence + * the const_cast; scheduling the scan mutates m_coverImage- + * ScansPending and connects a QFutureWatcher, neither of which + * fits a `mutable` member alone the way m_coverImageCache's own + * plain insert does. */ + const_cast(this)->scheduleCoverImageScan( + id, FS::PathCombine(pdata->gameRoot(), "screenshots")); + return QString(); + } + case HasCrashedRole: { + return pdata->hasCrashed(); } default: break; @@ -307,6 +380,13 @@ QHash InstanceList::roleNames() const roles.insert(CanLaunchRole, "canLaunch"); roles.insert(LastLaunchRole, "lastLaunch"); roles.insert(TotalTimePlayedRole, "totalTimePlayed"); + roles.insert(GameVersionRole, "gameVersion"); + roles.insert(LoaderRole, "loader"); + roles.insert(IconTintRole, "iconTint"); + roles.insert(LaunchStatusRole, "launchStatus"); + roles.insert(LaunchProgressRole, "launchProgress"); + roles.insert(CoverImageRole, "coverImage"); + roles.insert(HasCrashedRole, "hasCrashed"); return roles; } @@ -716,6 +796,13 @@ InstanceList::InstListError InstanceList::loadList() for (auto& removedItem : deadList) { auto instPtr = removedItem.first; instPtr->invalidate(); + // Otherwise these per-id caches would grow for as long as the + // launcher runs, bounded only by every instance ever seen + // rather than the ones currently in m_instances. + const InstanceId removedId = instPtr->id(); + m_coverImageCache.remove(removedId); + m_coverImageScansPending.remove(removedId); + m_coverImageGeneration.remove(removedId); currentItem = removedItem.second; if (back_bookmark == -1) { // no bookmark yet @@ -763,10 +850,180 @@ void InstanceList::add(const QList& t) for (auto& ptr : t) { connect(ptr.get(), &BaseInstance::propertiesChanged, this, &InstanceList::propertiesChanged); + trackLaunchProgress(ptr.get()); } endInsertRows(); } +void InstanceList::trackLaunchProgress(BaseInstance* inst) +{ + /* Parented to the instance, so it is destroyed along with it rather + * than needing its own removal logic here. */ + auto* tracker = new LaunchProgressTracker(inst); + m_launchTrackers.insert(inst, tracker); + connect(inst, &QObject::destroyed, this, + [this, inst]() { m_launchTrackers.remove(inst); }); + + // A launch task appearing or changing is the tracker's whole job. + connect(inst, &BaseInstance::launchTaskChanged, this, + [tracker](shared_qobject_ptr task) { + tracker->watch(task.get()); + /* The task keeps running for as long as the game does, but + * once the game process is up there is nothing left to + * report: from here on the instance is simply running. */ + if (task) { + connect(task.get(), &LaunchTask::readyForLaunch, tracker, + &LaunchProgressTracker::clear); + } + }); + connect(tracker, &LaunchProgressTracker::changed, this, + [this, inst]() { emitLaunchProgressChanged(inst); }); + + /* isRunning() does not come from the tracker - it is a property of + * the instance itself - but its transitions are exactly the moments + * a launch card needs to redraw, same as the two roles above. */ + connect(inst, &BaseInstance::runningStatusChanged, this, + [this, inst](bool) { emitIsRunningChanged(inst); }); +} + +void InstanceList::emitLaunchProgressChanged(BaseInstance* inst) +{ + int i = getInstIndex(inst); + if (i != -1) { + emit dataChanged(index(i), index(i), + {LaunchStatusRole, LaunchProgressRole}); + } +} + +void InstanceList::emitIsRunningChanged(BaseInstance* inst) +{ + int i = getInstIndex(inst); + if (i == -1) { + return; + } + QList roles{IsRunningRole}; + // The instance just stopped, not started: a play session is exactly + // when new screenshots tend to appear, so the cached cover - if any - + // may now be stale. Dropped rather than refreshed eagerly, since + // CoverImageRole's data() case fills it back in lazily on next ask. + if (!inst->isRunning()) { + const InstanceId id = inst->id(); + /* Bumped whether or not anything was cached yet: a scan already in + * flight for this id (started before the session ended, so looking + * at a screenshots folder from before it) is just as stale as a + * cached value would be, and scheduleCoverImageScan()'s completion + * handler uses this to discard that result instead of caching it. */ + ++m_coverImageGeneration[id]; + if (m_coverImageCache.remove(id) > 0) { + roles.append(CoverImageRole); + } + } + emit dataChanged(index(i), index(i), roles); +} + +void InstanceList::scheduleCoverImageScan(const InstanceId& id, + const QString& screenshotsDir) +{ + if (m_coverImageScansPending.contains(id)) { + // Already scanning; the caller (data()) will get the answer once + // that scan's dataChanged arrives, same as if it had asked again a + // moment later. + return; + } + m_coverImageScansPending.insert(id); + const int generation = m_coverImageGeneration.value(id, 0); + + auto* watcher = new QFutureWatcher(this); + connect(watcher, &QFutureWatcher::finished, this, + [this, id, generation, watcher]() { + const QString url = watcher->result(); + watcher->deleteLater(); + m_coverImageScansPending.remove(id); + + if (m_coverImageGeneration.value(id, 0) != generation) { + /* The instance stopped running while this scan was in + * flight, so it was looking at a screenshots folder + * from before that - not cached as if it were current; + * a fresh scan replaces it instead. */ + if (InstancePtr inst = getInstanceById(id)) { + scheduleCoverImageScan( + id, + FS::PathCombine(inst->gameRoot(), "screenshots")); + } + return; + } + + m_coverImageCache.insert(id, url); + InstancePtr inst = getInstanceById(id); + if (!inst) { + // The instance is gone by now; nothing left to update. + return; + } + const int row = getInstIndex(inst.get()); + if (row != -1) { + emit dataChanged(index(row), index(row), + {CoverImageRole}); + } + }); + watcher->setFuture(QtConcurrent::run(QThreadPool::globalInstance(), + &InstanceList::newestScreenshotUrl, + screenshotsDir)); +} + +QString InstanceList::newestScreenshotUrl(const QString& screenshotsDir) +{ + if (screenshotsDir.isEmpty()) { + return QString(); + } + QDir dir(screenshotsDir); + if (!dir.exists()) { + return QString(); + } + + // Same three extensions ScreenshotListModel::isImageFile() accepts, + // checked the same case-insensitive way - QDir name filters are + // case-sensitive on some platforms and not others, which would make + // this list agree with the Screenshots tab on some machines and not + // others. + static const QStringList kExtensions = { + QStringLiteral("png"), + QStringLiteral("jpg"), + QStringLiteral("jpeg"), + }; + + QString newestPath; + QString newestName; + QDateTime newestModified; + const QFileInfoList files = + dir.entryInfoList(QDir::Files | QDir::Readable, QDir::NoSort); + for (const QFileInfo& info : files) { + bool isImage = false; + for (const QString& ext : kExtensions) { + if (info.suffix().compare(ext, Qt::CaseInsensitive) == 0) { + isImage = true; + break; + } + } + if (!isImage) { + continue; + } + + const QDateTime modified = info.lastModified(); + // Newest-modified first, file name as a tiebreak - mirrors + // ScreenshotListModel::listEntries()'s sort so this always agrees + // with what the Screenshots tab shows as its top entry. + if (newestPath.isEmpty() || modified > newestModified || + (modified == newestModified && info.fileName() > newestName)) { + newestPath = info.absoluteFilePath(); + newestName = info.fileName(); + newestModified = modified; + } + } + + return newestPath.isEmpty() ? QString() + : QUrl::fromLocalFile(newestPath).toString(); +} + void InstanceList::resumeWatch() { if (m_watchLevel > 0) { diff --git a/launcher/InstanceList.h b/launcher/InstanceList.h index e5bf9047..08b94bd1 100644 --- a/launcher/InstanceList.h +++ b/launcher/InstanceList.h @@ -33,6 +33,7 @@ class QFileSystemWatcher; class InstanceTask; +class LaunchProgressTracker; using InstanceId = QString; using GroupId = QString; using InstanceLocator = std::pair; @@ -116,7 +117,7 @@ class InstanceList : public QAbstractListModel /* Roles added for the QML instance list. instanceId, name, iconKey, * instanceRoot and group are named in roleNames() but reuse the * InstanceIDRole/Qt::DisplayRole/Qt::DecorationRole/Qt::ToolTipRole/ - * GroupRole cases already handled in data() - only the four below are + * GroupRole cases already handled in data() - only the ten below are * genuinely new. InstancePointerRole is deliberately left unnamed: it * is a raw void*, and QML has no way to dereference one; a QML * delegate reaches an instance by instanceId instead. */ @@ -124,7 +125,42 @@ class InstanceList : public QAbstractListModel IsRunningRole = Qt::UserRole + 10, CanLaunchRole, LastLaunchRole, - TotalTimePlayedRole + TotalTimePlayedRole, + GameVersionRole, + LoaderRole, + IconTintRole, + /* Human-readable status/step text of the instance's active + * launch task (e.g. "Downloading assets..."). Empty when no + * launch is in progress, and empty again once the game is + * running - pair it with LaunchProgressRole to tell those two + * apart. */ + LaunchStatusRole, + /* 0..1 while the active launch task reports determinate + * progress; -1 while it is indeterminate; and -1 as well when + * nothing is happening at all, in which case LaunchStatusRole + * is also empty. */ + LaunchProgressRole, + /* file:// URL of the newest image in the instance's screenshots + * folder (the same folder InstanceDetails::screenshotsDir() + * resolves for the Screenshots tab), or an empty string when it + * has none or has not been looked up yet. The lookup + * (newestScreenshotUrl(), a directory scan) runs on a QThreadPool + * worker thread rather than inside data() itself - see + * scheduleCoverImageScan() - so data() always returns immediately: + * the cached value from m_coverImageCache if there is one, or an + * empty string while the first scan for that row is still in + * flight. The cache entry is dropped and dataChanged is emitted for + * the row when the instance stops running, since a play session + * commonly leaves new screenshots behind - see + * emitIsRunningChanged(). */ + CoverImageRole, + /* Whether the instance's last launch crashed - BaseInstance:: + * hasCrashed(), set by LaunchTask around the game process exit. + * setCrashed() already emits BaseInstance::propertiesChanged(), + * which InstanceList::propertiesChanged() (connected for every + * instance in add()) turns into a row-wide dataChanged(); no + * separate notification wiring is needed here. */ + HasCrashedRole }; /*! * \brief Error codes returned by functions in the InstanceList class. @@ -299,6 +335,29 @@ class InstanceList : public QAbstractListModel int getTotalPlayTime(); + /* Newest-modified png/jpg/jpeg directly inside @p screenshotsDir, as a + * file:// URL Image.source can load, or an empty string if the + * directory has none (including if it does not exist). A one-shot + * scan with no watcher of its own - CoverImageRole's cache in data() + * is what keeps this from running on every paint. Pure (no access to + * this InstanceList or any QObject), so scheduleCoverImageScan() can + * also run it on a QThreadPool worker thread instead of calling it + * straight from data(). + * + * Mirrors ScreenshotListModel::listEntries()'s newest-first ordering + * (mtime descending, file name as a tiebreak) so the cover always + * agrees with what the Screenshots tab shows as its first entry, but + * does not call into it: that model's directory scan is private, and + * building a full ScreenshotListModel (with its own QFileSystemWatcher) + * just to read one path back out would be a heavier and stranger tool + * than a plain directory listing needs. + * + * Exposed as a static, pure function - rather than folded straight + * into data() - so this lookup can be unit-tested on its own, without + * constructing a full InstanceList plus a BaseInstance. + */ + static QString newestScreenshotUrl(const QString& screenshotsDir); + Qt::DropActions supportedDragActions() const override; Qt::DropActions supportedDropActions() const override; @@ -366,6 +425,29 @@ class InstanceList : public QAbstractListModel * watcher onto them. */ void applyInstanceDirs(const QStringList& resolved); + /* Wire @p inst's launch-progress reporting for the QML roles: + * a LaunchProgressTracker (parented to the instance, so it goes away + * with it) watches whichever LaunchTask is current, and dataChanged + * is emitted for this instance's row whenever that changes or + * isRunning() does. Called once, from add(). */ + void trackLaunchProgress(BaseInstance* inst); + void emitLaunchProgressChanged(BaseInstance* inst); + /* Emits IsRunningRole's dataChanged, and - when @p inst just stopped - + * also drops its m_coverImageCache entry and emits dataChanged for + * CoverImageRole, since a session that just ended is exactly when a + * new screenshot is likely to have appeared. */ + void emitIsRunningChanged(BaseInstance* inst); + /* Starts a background scan of @p screenshotsDir for CoverImageRole's + * data() case, unless one for @p id is already running. Runs + * newestScreenshotUrl() on a QThreadPool worker thread; when it + * finishes, the result is stored in m_coverImageCache and dataChanged + * is emitted for that row's CoverImageRole - unless m_coverImageGeneration + * moved on for @p id while the scan was in flight (the instance stopped + * running - see emitIsRunningChanged()), in which case the result is + * discarded as stale and a fresh scan is started in its place. */ + void scheduleCoverImageScan(const InstanceId& id, + const QString& screenshotsDir); + private: int m_watchLevel = 0; int totalPlayTime = 0; @@ -394,4 +476,24 @@ class InstanceList : public QAbstractListModel QList m_trashHistory; bool m_groupsLoaded = false; bool m_instancesProbed = false; + /* One tracker per instance, for LaunchStatusRole/LaunchProgressRole - + * see trackLaunchProgress(). The tracker itself is owned by the + * instance (QObject parenting); this is only a lookup table for + * data(), kept in sync with the instance's destroyed() signal. */ + QHash m_launchTrackers; + /* Newest-screenshot URL per instance id, for CoverImageRole - filled + * lazily by data() rather than scanned for every row on every paint, + * and dropped for a row when its instance stops running (see + * emitIsRunningChanged()). Mutable because data() is const; an id + * absent from this map simply has not been looked up yet, and a + * present empty string means "looked up, no screenshot found". */ + mutable QHash m_coverImageCache; + /* Ids with a CoverImageRole scan currently running on a worker thread - + * see scheduleCoverImageScan(). Guards against data() queuing a second + * QtConcurrent::run() for the same id while the first has not returned; + * mutable for the same reason m_coverImageCache is. */ + mutable QSet m_coverImageScansPending; + /* Bumped for an id whenever emitIsRunningChanged() invalidates its + * cached cover - see scheduleCoverImageScan()'s doc comment for why. */ + QHash m_coverImageGeneration; }; diff --git a/launcher/InstanceList_test.cpp b/launcher/InstanceList_test.cpp new file mode 100644 index 00000000..ed432de4 --- /dev/null +++ b/launcher/InstanceList_test.cpp @@ -0,0 +1,298 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include +#include +#include +#include +#include +#include + +#include "InstanceList.h" +#include "NullInstance.h" +#include "settings/INISettingsObject.h" + +namespace +{ +bool writeFile(const QString& path, const QByteArray& contents, + const QDateTime& modified) +{ + QFile file(path); + if (!file.open(QIODevice::WriteOnly)) { + return false; + } + if (file.write(contents) != contents.size()) { + return false; + } + // Flushed before the explicit mtime is set, same as + // ScreenshotListModel_test.cpp's helper - a write landing after + // setFileTime() would bump the mtime back to "now". + if (!file.flush()) { + return false; + } + const bool timeSet = + file.setFileTime(modified, QFileDevice::FileModificationTime); + file.close(); + return timeSet; +} + +/* Same set of global settings models/InstanceDetails_test.cpp's + * makeGlobalSettings() registers - BaseInstance's constructor (run for + * every concrete instance InstanceList::loadInstance() creates) overrides + * or passes through exactly these ids, and a globalSettings without one of + * them makes registration hand back a null Setting. */ +SettingsObjectPtr makeGlobalSettings(QTemporaryDir& dir) +{ + auto settings = + std::make_shared(dir.filePath("global.ini")); + settings->registerSetting("PreLaunchCommand", ""); + settings->registerSetting("WrapperCommand", ""); + settings->registerSetting("PostExitCommand", ""); + settings->registerSetting("ShowConsole", true); + settings->registerSetting("AutoCloseConsole", false); + settings->registerSetting("ShowConsoleOnError", true); + settings->registerSetting("LogPrePostOutput", true); + settings->registerSetting("ConsoleMaxLines", 100000); + settings->registerSetting("ConsoleOverflowStop", true); + return settings; +} +} // namespace + +/* + * The first few tests exercise InstanceList::newestScreenshotUrl() - the + * lookup CoverImageRole schedules a background scan for in data() - directly, + * rather than through a full InstanceList: that needs a SettingsObjectPtr and + * real BaseInstance subclasses to construct, which would make those tests + * about instance bookkeeping InstanceList already has other coverage for, + * not about the screenshot lookup itself. HasCrashedRole has no such + * standalone helper - the flag lives on BaseInstance - and CoverImageRole's + * own async scheduling/caching/invalidation needs a real row to ask data() + * about, so those tests build the minimal real InstanceList+NullInstance + * fixture instead (same fixture shape as models/InstanceDetails_test.cpp's), + * waiting out the background scan with QTRY_COMPARE. + */ +class InstanceListTest : public QObject +{ + Q_OBJECT + private slots: + + void picksNewestModifiedImage() + { + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + const QString dir = tempDir.path(); + + const QDateTime base = QDateTime::currentDateTime(); + QVERIFY(writeFile(QDir(dir).filePath("older.png"), "a", + base.addSecs(-60))); + const QString newerPath = QDir(dir).filePath("newer.jpg"); + QVERIFY(writeFile(newerPath, "bb", base)); + + QCOMPARE(InstanceList::newestScreenshotUrl(dir), + QUrl::fromLocalFile(QFileInfo(newerPath).absoluteFilePath()) + .toString()); + } + + void ignoresNonImageFiles() + { + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + const QString dir = tempDir.path(); + + QVERIFY(writeFile(QDir(dir).filePath("notes.txt"), "c", + QDateTime::currentDateTime())); + + QCOMPARE(InstanceList::newestScreenshotUrl(dir), QString()); + } + + void matchesExtensionsCaseInsensitively() + { + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + const QString dir = tempDir.path(); + + const QString path = QDir(dir).filePath("shot.JPEG"); + QVERIFY(writeFile(path, "x", QDateTime::currentDateTime())); + + QCOMPARE( + InstanceList::newestScreenshotUrl(dir), + QUrl::fromLocalFile(QFileInfo(path).absoluteFilePath()).toString()); + } + + void emptyForMissingOrEmptyDirectory() + { + QCOMPARE(InstanceList::newestScreenshotUrl(QString()), QString()); + + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + QCOMPARE(InstanceList::newestScreenshotUrl( + QDir(tempDir.path()).filePath("does-not-exist")), + QString()); + QCOMPARE(InstanceList::newestScreenshotUrl(tempDir.path()), QString()); + } + + void hasCrashedRoleReflectsInstanceState() + { + QTemporaryDir globalDir; + QVERIFY(globalDir.isValid()); + QTemporaryDir instsDir; + QVERIFY(instsDir.isValid()); + SettingsObjectPtr globalSettings = makeGlobalSettings(globalDir); + + const QString instRoot = QDir(instsDir.path()).filePath("testinst"); + QVERIFY(QDir().mkpath(instRoot)); + /* Unrecognized InstanceType, same as a folder InstanceList itself + * cannot make sense of: loadInstance() falls back to NullInstance + * for anything that is not OneSix/Nostalgia/Legacy, which is the + * one concrete BaseInstance simple enough to construct here. */ + QVERIFY(writeFile(QDir(instRoot).filePath("instance.cfg"), + "InstanceType=NullTest\n", + QDateTime::currentDateTime())); + + InstanceList list(globalSettings, {instsDir.path()}); + QCOMPARE(list.loadList(), InstanceList::NoError); + + InstancePtr inst = list.getInstanceById("testinst"); + QVERIFY(inst); + const QModelIndex idx = list.getInstanceIndexById("testinst"); + QVERIFY(idx.isValid()); + + QCOMPARE(list.roleNames().value(InstanceList::HasCrashedRole), + QByteArray("hasCrashed")); + QCOMPARE(list.data(idx, InstanceList::HasCrashedRole).toBool(), + false); + + QSignalSpy dataChangedSpy(&list, &InstanceList::dataChanged); + inst->setCrashed(true); + QVERIFY(!dataChangedSpy.isEmpty()); + QCOMPARE(list.data(idx, InstanceList::HasCrashedRole).toBool(), true); + + dataChangedSpy.clear(); + inst->setCrashed(false); + QVERIFY(!dataChangedSpy.isEmpty()); + QCOMPARE(list.data(idx, InstanceList::HasCrashedRole).toBool(), + false); + } + + void coverImageRoleScansAsynchronouslyAndCachesResult() + { + QTemporaryDir globalDir; + QVERIFY(globalDir.isValid()); + QTemporaryDir instsDir; + QVERIFY(instsDir.isValid()); + SettingsObjectPtr globalSettings = makeGlobalSettings(globalDir); + + /* Canonicalized before anything is built from it: InstanceList + * itself canonicalizes every configured instance root (resolving + * a symlink like macOS's /var -> /private/var), so building + * expectedUrl from the raw, un-resolved QTemporaryDir path would + * compare two spellings of the same file and never match. */ + const QString instsRoot = + QFileInfo(instsDir.path()).canonicalFilePath(); + const QString instRoot = QDir(instsRoot).filePath("testinst"); + QVERIFY(QDir().mkpath(instRoot)); + QVERIFY(writeFile(QDir(instRoot).filePath("instance.cfg"), + "InstanceType=NullTest\n", + QDateTime::currentDateTime())); + const QString screenshotsDir = + QDir(instRoot).filePath("screenshots"); + QVERIFY(QDir().mkpath(screenshotsDir)); + const QString shotPath = QDir(screenshotsDir).filePath("shot.png"); + QVERIFY(writeFile(shotPath, "x", QDateTime::currentDateTime())); + const QString expectedUrl = + QUrl::fromLocalFile(QFileInfo(shotPath).absoluteFilePath()) + .toString(); + + InstanceList list(globalSettings, {instsDir.path()}); + QCOMPARE(list.loadList(), InstanceList::NoError); + const QModelIndex idx = list.getInstanceIndexById("testinst"); + QVERIFY(idx.isValid()); + + // Nothing cached yet - data() must answer immediately (empty) + // rather than block on the directory scan, and repeated asks before + // the scan comes back must not queue a second one (no direct probe + // for that here, but a duplicate scan finishing later would still + // only re-publish the same, correct URL below). + QCOMPARE(list.data(idx, InstanceList::CoverImageRole).toString(), + QString()); + QCOMPARE(list.data(idx, InstanceList::CoverImageRole).toString(), + QString()); + + QTRY_COMPARE(list.data(idx, InstanceList::CoverImageRole).toString(), + expectedUrl); + } + + void coverImageRoleRescansAfterInstanceStops() + { + QTemporaryDir globalDir; + QVERIFY(globalDir.isValid()); + QTemporaryDir instsDir; + QVERIFY(instsDir.isValid()); + SettingsObjectPtr globalSettings = makeGlobalSettings(globalDir); + + // See coverImageRoleScansAsynchronouslyAndCachesResult() for why + // this is canonicalized first. + const QString instsRoot = + QFileInfo(instsDir.path()).canonicalFilePath(); + const QString instRoot = QDir(instsRoot).filePath("testinst"); + QVERIFY(QDir().mkpath(instRoot)); + QVERIFY(writeFile(QDir(instRoot).filePath("instance.cfg"), + "InstanceType=NullTest\n", + QDateTime::currentDateTime())); + const QString screenshotsDir = + QDir(instRoot).filePath("screenshots"); + QVERIFY(QDir().mkpath(screenshotsDir)); + const QString shotPath = QDir(screenshotsDir).filePath("shot.png"); + QVERIFY(writeFile(shotPath, "x", QDateTime::currentDateTime())); + const QString expectedUrl = + QUrl::fromLocalFile(QFileInfo(shotPath).absoluteFilePath()) + .toString(); + + InstanceList list(globalSettings, {instsDir.path()}); + QCOMPARE(list.loadList(), InstanceList::NoError); + InstancePtr inst = list.getInstanceById("testinst"); + QVERIFY(inst); + const QModelIndex idx = list.getInstanceIndexById("testinst"); + QVERIFY(idx.isValid()); + + // Let the first scan land and populate the cache. + QCOMPARE(list.data(idx, InstanceList::CoverImageRole).toString(), + QString()); + QTRY_COMPARE(list.data(idx, InstanceList::CoverImageRole).toString(), + expectedUrl); + + // A play session ending is exactly when a new screenshot tends to + // appear, so the cached cover is dropped - immediately, not only + // once a fresh scan happens to finish. + inst->setRunning(true); + inst->setRunning(false); + QCOMPARE(list.data(idx, InstanceList::CoverImageRole).toString(), + QString()); + + // data() above already asked again, which schedules a fresh scan; + // it eventually lands the same (still correct) URL. + QTRY_COMPARE(list.data(idx, InstanceList::CoverImageRole).toString(), + expectedUrl); + } +}; + +QTEST_GUILESS_MAIN(InstanceListTest) + +#include "InstanceList_test.moc" diff --git a/launcher/MeshMC.in b/launcher/MeshMC.in index 9bdebf4a..deb3aca6 100755 --- a/launcher/MeshMC.in +++ b/launcher/MeshMC.in @@ -25,6 +25,13 @@ echo "Launcher Dir: ${LAUNCHER_DIR}" # Makes the launcher use portals for file picking export QT_QPA_PLATFORMTHEME=xdgdesktopportal +# Qt's own QML modules (QtQuick, QtQuick.Controls, ...) are packaged +# alongside this script under qml/ -- see the QML deployment step in +# .github/actions/package/linux/action.yml -- and are not found by Qt +# otherwise, since qt.conf is not part of this portable layout. +export QML2_IMPORT_PATH="${LAUNCHER_DIR}/qml" +export QML_IMPORT_PATH="${LAUNCHER_DIR}/qml" + # disable OpenGL and Vulkan launcher features on sharun until https://github.com/VHSgunzo/sharun/issues/35 if [[ -f "${LAUNCHER_DIR}/sharun" ]]; then export ${LAUNCHER_ENVNAME}_DISABLE_GLVULKAN=1 diff --git a/launcher/SkinUtils.cpp b/launcher/SkinUtils.cpp index 8f76bf4c..a28115bd 100644 --- a/launcher/SkinUtils.cpp +++ b/launcher/SkinUtils.cpp @@ -20,7 +20,7 @@ #include "SkinUtils.h" #include "net/HttpMetaCache.h" -#include "Application.h" +#include "core/LauncherContext.h" #include #include @@ -36,7 +36,7 @@ namespace SkinUtils */ QPixmap getFaceFromCache(QString username, int height, int width) { - QFile fskin(APPLICATION->metacache() + QFile fskin(LAUNCHER->metacache() ->resolveEntry("skins", username + ".png") ->getFullPath()); diff --git a/launcher/VersionProxyModel.cpp b/launcher/VersionProxyModel.cpp index c5dc07d3..b7466255 100644 --- a/launcher/VersionProxyModel.cpp +++ b/launcher/VersionProxyModel.cpp @@ -18,7 +18,7 @@ */ #include "VersionProxyModel.h" -#include "Application.h" +#include "core/LauncherContext.h" #include #include #include @@ -214,15 +214,15 @@ QVariant VersionProxyModel::data(const QModelIndex& index, int role) const auto value = sourceModel()->data( parentIndex, BaseVersionList::RecommendedRole); if (value.toBool()) { - return APPLICATION->getThemedIcon("star"); + return LAUNCHER->getThemedIcon("star"); } else if (hasLatest) { auto value = sourceModel()->data( parentIndex, BaseVersionList::LatestRole); if (value.toBool()) { - return APPLICATION->getThemedIcon("bug"); + return LAUNCHER->getThemedIcon("bug"); } } else if (index.row() == 0) { - return APPLICATION->getThemedIcon("bug"); + return LAUNCHER->getThemedIcon("bug"); } QPixmap pixmap; if (!QPixmapCache::find("placeholder", &pixmap)) { diff --git a/launcher/core/AuthRequestDecorator.h b/launcher/core/AuthRequestDecorator.h new file mode 100644 index 00000000..a8b8007e --- /dev/null +++ b/launcher/core/AuthRequestDecorator.h @@ -0,0 +1,53 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +class QNetworkRequest; + +/* + * Lets something outside the core add headers to an outgoing authentication + * request. + * + * In practice the implementation is the plugin host, which forwards to the + * MMCO auth-request hook. AuthRequest needs that hook, but PluginManager + * renders plugin-supplied user interface and so drags in QtWidgets; having the + * authentication code call it directly would tie the core to the widget + * toolkit through a single line. Hence this interface: the core declares what + * it needs, the plugin layer supplies it, and neither knows about the other. + */ +class AuthRequestDecorator +{ + public: + virtual ~AuthRequestDecorator() = default; + + /* Header and redirect changes are applied to `request` in place. + * + * Returns true when the request was CANCELLED -- the caller must then + * abort it and report a network error. It does NOT return true merely + * because the request was modified. + * + * `method` is the HTTP verb as an ASCII literal ("GET", "POST"); `body` + * is empty for verbs that carry none. */ + virtual bool dispatchAuthRequest(QNetworkRequest& request, + const QByteArray& body, + const char* method) = 0; +}; diff --git a/launcher/core/CoreLinkCheck.cpp b/launcher/core/CoreLinkCheck.cpp new file mode 100644 index 00000000..0a250519 --- /dev/null +++ b/launcher/core/CoreLinkCheck.cpp @@ -0,0 +1,27 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* + * Intentionally empty. What matters is not what this program does but whether + * it links: see MeshMC_core_link_check in launcher/CMakeLists.txt. + */ +int main() +{ + return 0; +} diff --git a/launcher/core/LauncherContext.cpp b/launcher/core/LauncherContext.cpp new file mode 100644 index 00000000..50d73a69 --- /dev/null +++ b/launcher/core/LauncherContext.cpp @@ -0,0 +1,38 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "core/LauncherContext.h" + +namespace +{ + /* Deliberately a plain pointer and not an owning one: the implementation + * is the Application object itself, which owns its own lifetime and + * unregisters on the way out. */ + LauncherContext* g_context = nullptr; +} + +LauncherContext* LauncherContext::instance() +{ + return g_context; +} + +void LauncherContext::setInstance(LauncherContext* context) +{ + g_context = context; +} diff --git a/launcher/core/LauncherContext.h b/launcher/core/LauncherContext.h new file mode 100644 index 00000000..9ef8ccf5 --- /dev/null +++ b/launcher/core/LauncherContext.h @@ -0,0 +1,123 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include + +#include "QObjectPtr.h" + +class AccountList; +class AuthRequestDecorator; +class HttpMetaCache; +class IconList; +class InstanceList; +class JavaInstallList; +class QNetworkAccessManager; +class SettingsObject; +class TranslationsModel; +class UiHost; + +namespace Meta +{ + class Index; +} + +/* + * The launcher services that code outside the user interface is allowed to + * reach for. + * + * Everything here used to be read off `Application`, which derives from + * QApplication and owns the main window, the settings dialog and the theme + * manager. That made every file that wanted, say, the network manager depend + * transitively on QtWidgets and on the entire widget page tree -- which is + * exactly what stops the core from being reused under a QML user interface. + * + * So the dependency is inverted: `Application` implements this interface and + * registers itself, and the core reaches services through LAUNCHER-> instead + * of APPLICATION->. Nothing declared here is allowed to mention QtWidgets, and + * nothing here returns a widget, a window or a dialog. Asking the user a + * question is not a service -- that inversion belongs to UiHost. + * + * QIcon is fine despite appearances: it lives in QtGui, not QtWidgets. QML + * cannot consume it, so getThemedIcon() is a transitional accessor that the + * image-provider work will eventually replace with icon names. + */ +class LauncherContext +{ + public: + virtual ~LauncherContext() = default; + + /* Null before Application's constructor has run and after it has been + * destroyed -- notably in the crash handler, which runs while the + * application object is being torn down. Callers on those paths must + * check. */ + static LauncherContext* instance(); + + virtual std::shared_ptr settings() const = 0; + virtual std::shared_ptr instances() const = 0; + virtual std::shared_ptr icons() const = 0; + virtual shared_qobject_ptr accounts() const = 0; + /* Not const: Application::translations() is loaded once during startup + * and just returns it, but Application::javalist() builds its + * JavaInstallList lazily on first call. Needed by QmlShell (MeshMC_qml, + * which links this core and not Application/MeshMC_logic) for the QML + * shell's own onboarding -- see QmlShell::languages()/javaInstalls(). */ + virtual std::shared_ptr translations() = 0; + virtual std::shared_ptr javalist() = 0; + + virtual shared_qobject_ptr network() = 0; + virtual shared_qobject_ptr metacache() = 0; + virtual shared_qobject_ptr metadataIndex() = 0; + + virtual QIcon getThemedIcon(const QString& name) = 0; + virtual QString getJarsPath() = 0; + virtual QString msaClientId() const = 0; + + /* Applies a proxy configuration to the whole application immediately + * (QNetworkProxy::setApplicationProxy() and friends), the same way the + * widget ProxyPage's apply button does. @p proxyTypeStr is one of + * "None", "Default", "SOCKS5", "HTTP". Needed by QmlShell, which + * cannot see Application/QNetworkProxy from MeshMC_qml -- see + * QmlShell::applyProxySettings(). */ + virtual void updateProxySettings(QString proxyTypeStr, QString addr, + int port, QString user, + QString password) = 0; + + /* Null when no plugin host is present -- plugins are an optional build + * (MeshMC_PLUGINS, OFF by default). Core code calls through this rather + * than reaching PluginManager directly, because PluginManager renders + * plugin-supplied user interface and therefore pulls in QtWidgets. */ + virtual AuthRequestDecorator* authRequestDecorator() const = 0; + + /* Never null: a core task that has to ask the user a question cannot + * meaningfully carry on without an answer, so the shell installs one + * before anything that might ask is allowed to run. */ + virtual UiHost* uiHost() const = 0; + + protected: + /* Called by the implementation's constructor/destructor. Registering is + * not thread safe and is expected to happen once, on the main thread, + * before anything else runs. */ + static void setInstance(LauncherContext* context); +}; + +#define LAUNCHER (LauncherContext::instance()) diff --git a/launcher/core/UiHost.h b/launcher/core/UiHost.h new file mode 100644 index 00000000..c0b09e85 --- /dev/null +++ b/launcher/core/UiHost.h @@ -0,0 +1,144 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include + +#include +#include +#include + +#include "minecraft/auth/MinecraftAccount.h" +#include "modplatform/BlockedMod.h" + +/* + * Questions the core needs answered by a human. + * + * Work that runs outside the user interface sometimes has to stop and ask -- + * an install task finding files the provider will not serve, or an update + * that would overwrite an instance. Those decisions used to be taken by + * constructing a QDialog inside the task, which put QtWidgets in the + * dependency graph of code that has nothing to do with drawing. + * + * So the core states the question and the shell answers it. What the answer + * looks like on screen -- a message box today, something in QML later -- is + * none of the core's business. + * + * ON BLOCKING: these are synchronous on purpose. The callers are decision + * points in the middle of a task, branching immediately on the answer, and + * making them asynchronous would mean restructuring the control flow of + * modpack installation for no gain today -- the widget implementation blocks + * either way. The interface says nothing about how the answer is obtained, + * so a future implementation is free to pump an event loop or to be called + * from a worker thread. That cost is real and deferred, not hidden: a + * blocking call reached from the GUI thread runs a nested event loop, with + * the re-entrancy that implies. + */ +class UiHost +{ + public: + /* Proof that the launcher has not frozen while the caller blocks on + * something it cannot report progress for. The indication disappears when + * this object is destroyed, so it cannot be left showing on an early + * return. */ + class BusyIndicator + { + public: + virtual ~BusyIndicator() = default; + }; + + enum class Severity { Information, Question, Warning, Critical }; + + virtual ~UiHost() = default; + + /* Not a question -- nothing to answer and nothing to cancel. For work that + * blocks without being able to say how far along it is. */ + virtual std::unique_ptr showBusy(const QString& text) = 0; + + /* Something the user only has to acknowledge. */ + virtual void message(const QString& title, const QString& text, + Severity severity) = 0; + + /* A two-way decision. True means the user agreed to go ahead. + * + * The labels are optional: leaving them empty gets the platform's own + * wording. Naming the actions is better where the question is not a + * plain yes/no -- "Remove saves" and "Keep saves" say what will happen, + * where "Yes" and "No" make the reader re-read the question. */ + virtual bool confirm(const QString& title, const QString& text, + Severity severity, + const QString& acceptLabel = QString(), + const QString& rejectLabel = QString()) = 0; + + /* A decision with more than two answers. Returns the index into + * `actions`, or -1 when the user backed out. */ + virtual int choose(const QString& title, const QString& text, + Severity severity, const QStringList& actions) = 0; + + /* A single line of free text, pre-filled with @p defaultValue -- + * "what should the demo player be called", "what name for offline + * mode". Returns the entered text, or nullopt if the user backed out + * (widget: cancelled/closed the dialog; QML: rejected the request). An + * empty string is a real answer -- callers that want to fall back to + * something else for it do so themselves, the way an empty offline + * name used to fall back to the session's existing one. */ + virtual std::optional askText( + const QString& title, const QString& text, + const QString& defaultValue = QString()) = 0; + + /* Files the provider's API will not serve, which the user fetches by + * hand while the host watches the download folder. `mods` is updated in + * place. False means the user gave up. */ + virtual bool resolveBlockedMods(const QString& title, const QString& text, + QList& mods) = 0; + + /* Files that failed the trust check. True means install them anyway. */ + virtual bool confirmUntrustedMods(const QStringList& suspectPaths) = 0; + + enum class UpdateChoice { Install, Later, Skip }; + + /* A release being offered: the current version, the version on offer, + * and its release notes. Kept separate from choose() because the notes + * are formatted content -- Markdown today -- and a plain message-box + * body would flatten that formatting. */ + virtual UpdateChoice offerUpdate(const QString& currentVersion, + const QString& availableVersion, + const QString& releaseNotes) = 0; + + /* A Microsoft account that owns Minecraft but has never set up a + * profile (username) yet -- checks name availability live as the user + * types and creates the profile once they accept, the way the widget + * ProfileSetupDialog does. Returns true once the profile has actually + * been created; false if the user backed out. */ + virtual bool setupProfile(MinecraftAccountPtr account) = 0; + + enum class FilePickerMode { Open, Save }; + + /* A file to open, or a location/name to save to -- what the plugin + * SDK's file dialogs need. @p filter is a Qt filter string ("Images + * (*.png *.jpg)", several groups separated by ";;"); @p defaultPath is + * only meaningful for FilePickerMode::Save (a suggested filename). + * Returns the chosen path, or nullopt if the user cancelled. */ + virtual std::optional pickFile(FilePickerMode mode, + const QString& title, + const QString& defaultPath, + const QString& filter) = 0; +}; diff --git a/launcher/icons/IconList.cpp b/launcher/icons/IconList.cpp index d62afd26..86a15ced 100644 --- a/launcher/icons/IconList.cpp +++ b/launcher/icons/IconList.cpp @@ -28,6 +28,8 @@ #include #include #include +#include +#include #define MAX_SIZE 1024 @@ -35,6 +37,11 @@ IconList::IconList(const QStringList& builtinPaths, QString path, QObject* parent) : QAbstractListModel(parent) { + // Connected first, so the stale colour is gone before anyone else + // hears about the change and asks for it again. + connect(this, &IconList::iconUpdated, this, + [this](const QString& key) { m_tintCache.remove(key); }); + QSet builtinNames; // add builtin icons @@ -237,6 +244,8 @@ QVariant IconList::data(const QModelIndex& index, int role) const return icons[row].name(); case Qt::UserRole: return icons[row].m_key; + case IsBuiltinRole: + return icons[row].isBuiltIn(); default: return QVariant(); } @@ -247,6 +256,15 @@ int IconList::rowCount(const QModelIndex& parent) const return icons.size(); } +QHash IconList::roleNames() const +{ + auto roles = QAbstractListModel::roleNames(); + roles.insert(Qt::DisplayRole, "name"); + roles.insert(Qt::UserRole, "key"); + roles.insert(IsBuiltinRole, "isBuiltin"); + return roles; +} + void IconList::installIcons(const QStringList& iconFiles) { for (QString file : iconFiles) { @@ -391,6 +409,39 @@ QIcon IconList::getIcon(const QString& key) const return QIcon(); } +QColor IconList::tint(const QString& key) const +{ + const auto cached = m_tintCache.constFind(key); + if (cached != m_tintCache.constEnd()) { + return *cached; + } + + const QImage image = getIcon(key) + .pixmap(QSize(32, 32)) + .toImage() + .convertToFormat(QImage::Format_ARGB32); + double red = 0, green = 0, blue = 0, total = 0; + for (int y = 0; y < image.height(); ++y) { + const auto* line = reinterpret_cast(image.constScanLine(y)); + for (int x = 0; x < image.width(); ++x) { + const QColor pixel = QColor::fromRgba(line[x]); + const double weight = + pixel.alphaF() * (0.25 + qMax(0.0f, pixel.hsvSaturationF())); + red += pixel.redF() * weight; + green += pixel.greenF() * weight; + blue += pixel.blueF() * weight; + total += weight; + } + } + + const QColor result = total > 0 + ? QColor::fromRgbF(red / total, green / total, + blue / total) + : QColor(); + m_tintCache.insert(key, result); + return result; +} + int IconList::getIconIndex(const QString& key) const { auto iter = name_index.find(key == "default" ? "grass" : key); diff --git a/launcher/icons/IconList.h b/launcher/icons/IconList.h index eca0e34d..46a2a8f9 100644 --- a/launcher/icons/IconList.h +++ b/launcher/icons/IconList.h @@ -25,6 +25,8 @@ #include #include #include +#include +#include #include #include "MMCIcon.h" @@ -38,11 +40,23 @@ class IconList : public QAbstractListModel { Q_OBJECT public: + /* QML-only role, additive alongside the Qt::DisplayRole/UserRole a + * QListView already gets from data() -- see roleNames(). Numbered like + * InstanceList's own QML-only roles (Qt::UserRole + 10 and up) to leave + * room without colliding if Qt::UserRole itself is ever repurposed + * here. */ + enum Roles { IsBuiltinRole = Qt::UserRole + 10 }; + explicit IconList(const QStringList& builtinPaths, QString path, QObject* parent = 0); virtual ~IconList() {}; QIcon getIcon(const QString& key) const; + /* The icon's characteristic colour, for tinting whatever surrounds it: + * an average weighted by opacity and saturation, so a mostly grey icon + * with a coloured accent is tinted by the accent. Cached per key until + * the icon changes; invalid if the icon has no visible pixels. */ + QColor tint(const QString& key) const; int getIconIndex(const QString& key) const; QString getDirectory() const; @@ -50,6 +64,11 @@ class IconList : public QAbstractListModel int role = Qt::DisplayRole) const override; virtual int rowCount(const QModelIndex& parent = QModelIndex()) const override; + /* Names Qt::DisplayRole/Qt::UserRole/IsBuiltinRole as `name`/`key`/ + * `isBuiltin` so a QML delegate (an icon picker grid) can bind to them + * by name, the way every other QML-facing model here does. Additive: + * the numeric roles a QListView already gets from data() do not move. */ + virtual QHash roleNames() const override; virtual QStringList mimeTypes() const override; virtual Qt::DropActions supportedDropActions() const override; @@ -97,4 +116,5 @@ class IconList : public QAbstractListModel QMap name_index; QVector icons; QDir m_dir; + mutable QHash m_tintCache; }; diff --git a/launcher/icons/IconList_test.cpp b/launcher/icons/IconList_test.cpp new file mode 100644 index 00000000..ca2ccf65 --- /dev/null +++ b/launcher/icons/IconList_test.cpp @@ -0,0 +1,116 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include +#include +#include + +#include "icons/IconList.h" + +/* + * Covers IconList::roleNames() -- the QML-facing addition an icon picker + * grid binds `key`/`name`/`isBuiltin` against (see + * qml/Components/IconPickerDialog.qml) -- without needing the compiled + * "multimc" icon theme resource InstanceIconProvider_test.cpp needs for real + * pixmaps: addThemeIcon()/addIcon() only touch the model's bookkeeping, never + * render anything. + */ +class IconListTest : public QObject +{ + Q_OBJECT + + private slots: + void init() + { + m_dir = std::make_unique(); + QVERIFY(m_dir->isValid()); + m_icons = std::make_unique(QStringList(), m_dir->path()); + } + + void cleanup() + { + m_icons.reset(); + m_dir.reset(); + } + + void roleNamesExposeKeyNameAndIsBuiltin() + { + const auto roles = m_icons->roleNames(); + QCOMPARE(roles.value(Qt::UserRole), QByteArray("key")); + QCOMPARE(roles.value(Qt::DisplayRole), QByteArray("name")); + QCOMPARE(roles.value(IconList::IsBuiltinRole), + QByteArray("isBuiltin")); + } + + void builtinIconReportsIsBuiltinRole() + { + QVERIFY(m_icons->addThemeIcon(QStringLiteral("grass"))); + + const int row = m_icons->getIconIndex(QStringLiteral("grass")); + QVERIFY(row != -1); + const QModelIndex index = m_icons->index(row); + + QCOMPARE(m_icons->data(index, Qt::UserRole).toString(), + QStringLiteral("grass")); + QVERIFY(m_icons->data(index, IconList::IsBuiltinRole).toBool()); + } + + void fileBasedIconReportsNotBuiltin() + { + // A 1x1 PNG is enough for QIcon to accept the file -- nothing here + // looks at the pixels. + const QString path = m_dir->filePath(QStringLiteral("custom.png")); + QImage image(1, 1, QImage::Format_RGB32); + image.fill(Qt::black); + QVERIFY(image.save(path, "PNG")); + + QVERIFY(m_icons->addIcon(QStringLiteral("custom"), + QStringLiteral("Custom"), path, + IconType::FileBased)); + + const int row = m_icons->getIconIndex(QStringLiteral("custom")); + QVERIFY(row != -1); + const QModelIndex index = m_icons->index(row); + + QCOMPARE(m_icons->data(index, Qt::DisplayRole).toString(), + QStringLiteral("Custom")); + QVERIFY(!m_icons->data(index, IconList::IsBuiltinRole).toBool()); + } + + private: + std::unique_ptr m_dir; + std::unique_ptr m_icons; +}; + +int main(int argc, char* argv[]) +{ + /* QImage::save()/addIcon() go through QtGui; offscreen keeps this + * runnable on a headless runner without depending on the harness to + * set QT_QPA_PLATFORM for us -- same reasoning as + * InstanceIconProvider_test.cpp. */ + qputenv("QT_QPA_PLATFORM", "offscreen"); + + QGuiApplication app(argc, argv); + + IconListTest test; + return QTest::qExec(&test, argc, argv); +} + +#include "IconList_test.moc" diff --git a/launcher/java/JavaChecker.cpp b/launcher/java/JavaChecker.cpp index bf8f0443..1b822956 100644 --- a/launcher/java/JavaChecker.cpp +++ b/launcher/java/JavaChecker.cpp @@ -28,14 +28,14 @@ #include "JavaUtils.h" #include "FileSystem.h" #include "Commandline.h" -#include "Application.h" +#include "core/LauncherContext.h" JavaChecker::JavaChecker(QObject* parent) : QObject(parent) {} void JavaChecker::performCheck() { QString checkerJar = - FS::PathCombine(APPLICATION->getJarsPath(), "JavaCheck.jar"); + FS::PathCombine(LAUNCHER->getJarsPath(), "JavaCheck.jar"); QStringList args; diff --git a/launcher/java/download/JavaDownloadTask.cpp b/launcher/java/download/JavaDownloadTask.cpp index 1730c1ba..3606e04d 100644 --- a/launcher/java/download/JavaDownloadTask.cpp +++ b/launcher/java/download/JavaDownloadTask.cpp @@ -26,12 +26,12 @@ #include #include -#include "Application.h" #include "FileSystem.h" #include "Json.h" #include "net/Download.h" #include "net/ChecksumValidator.h" #include "MMCZip.h" +#include "core/LauncherContext.h" JavaDownloadTask::JavaDownloadTask(const JavaDownload::RuntimeEntry& runtime, const QString& targetDir, QObject* parent) @@ -64,7 +64,7 @@ void JavaDownloadTask::downloadArchive() return; } - m_downloadJob = new NetJob(tr("Java download"), APPLICATION->network()); + m_downloadJob = new NetJob(tr("Java download"), LAUNCHER->network()); auto dl = Net::Download::makeFile(m_runtime.url, m_archivePath); @@ -188,7 +188,7 @@ void JavaDownloadTask::downloadManifest() } m_downloadJob = - new NetJob(tr("Java manifest download"), APPLICATION->network()); + new NetJob(tr("Java manifest download"), LAUNCHER->network()); auto dl = Net::Download::makeByteArray(QUrl(m_runtime.url), &m_manifestData); @@ -242,7 +242,7 @@ void JavaDownloadTask::manifestDownloaded() // Queue file downloads setStatus(tr("Downloading %1 files...").arg(m_runtime.name)); m_downloadJob = - new NetJob(tr("Java runtime files"), APPLICATION->network()); + new NetJob(tr("Java runtime files"), LAUNCHER->network()); for (auto it = files.begin(); it != files.end(); ++it) { auto entry = it.value().toObject(); diff --git a/launcher/launch/LaunchProgressTracker.cpp b/launcher/launch/LaunchProgressTracker.cpp new file mode 100644 index 00000000..2cca2623 --- /dev/null +++ b/launcher/launch/LaunchProgressTracker.cpp @@ -0,0 +1,116 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "LaunchProgressTracker.h" + +#include "tasks/Task.h" + +#include + +#include + +LaunchProgressTracker::LaunchProgressTracker(QObject* parent) : QObject(parent) {} + +void LaunchProgressTracker::watch(Task* task) +{ + if (task == m_task) { + return; + } + if (!task) { + clear(); + return; + } + stopWatching(); + m_task = task; + m_connections << connect(task, &Task::status, this, + &LaunchProgressTracker::onStatus); + m_connections << connect(task, &Task::progress, this, + &LaunchProgressTracker::onProgress); + m_connections << connect(task, &Task::finished, this, + &LaunchProgressTracker::onFinished); + // In case whatever owns the task drops it without it ever finishing. + m_connections << connect(task, &QObject::destroyed, this, + &LaunchProgressTracker::onFinished); +} + +void LaunchProgressTracker::clear() +{ + stopWatching(); + m_status.clear(); + m_progress = -1; + emitNow(); +} + +void LaunchProgressTracker::stopWatching() +{ + for (const QMetaObject::Connection& connection : m_connections) { + QObject::disconnect(connection); + } + m_connections.clear(); + m_task = nullptr; +} + +void LaunchProgressTracker::onStatus(const QString& status) +{ + m_status = status; + scheduleEmit(); +} + +void LaunchProgressTracker::onProgress(qint64 current, qint64 total) +{ + m_progress = total > 0 ? (double(current) / double(total)) : -1.0; + scheduleEmit(); +} + +void LaunchProgressTracker::onFinished() +{ + /* The task we were watching succeeded, failed, was aborted, or was + * simply destroyed - either way there is nothing left to report. + * Reported immediately rather than coalesced: sitting on a stale + * "Downloading..." line after the game has already started would be + * worse than one extra redraw. */ + stopWatching(); + m_status.clear(); + m_progress = -1; + emitNow(); +} + +void LaunchProgressTracker::scheduleEmit() +{ + if (!m_sinceLastEmit.isValid() || m_sinceLastEmit.elapsed() >= m_minIntervalMs) { + emitNow(); + return; + } + if (!m_pendingTimer) { + m_pendingTimer = new QTimer(this); + m_pendingTimer->setSingleShot(true); + connect(m_pendingTimer, &QTimer::timeout, this, + &LaunchProgressTracker::emitNow); + } + if (!m_pendingTimer->isActive()) { + const qint64 remaining = m_minIntervalMs - m_sinceLastEmit.elapsed(); + m_pendingTimer->start(static_cast(std::max(0, remaining))); + } +} + +void LaunchProgressTracker::emitNow() +{ + m_sinceLastEmit.start(); + emit changed(); +} diff --git a/launcher/launch/LaunchProgressTracker.h b/launcher/launch/LaunchProgressTracker.h new file mode 100644 index 00000000..6fb09e30 --- /dev/null +++ b/launcher/launch/LaunchProgressTracker.h @@ -0,0 +1,110 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include +#include + +class Task; +class QTimer; + +/** + * Watches one Task's status()/progress()/finished() signals and turns them + * into the pair of values a launch card wants to show: a human-readable + * status line, and a 0..1 progress fraction (-1 while indeterminate, or + * once nothing is being watched at all). + * + * Exists so InstanceList does not have to juggle per-instance + * QMetaObject::Connection bookkeeping itself, and so this piece can be unit + * tested against a plain Task subclass instead of a real launch. + */ +class LaunchProgressTracker : public QObject +{ + Q_OBJECT + + public: + explicit LaunchProgressTracker(QObject* parent = nullptr); + + /** + * Start watching @p task instead of whatever was being watched + * before. A null task (or the task already being watched) is a + * no-op / clear(), same as when the watched task finishes or is + * destroyed on its own. + */ + void watch(Task* task); + + /// Stop watching and go back to the idle state (empty status(), -1 + /// progress()), announced right away rather than coalesced. + void clear(); + + /// Current human-readable status/step text; empty when idle. + QString status() const + { + return m_status; + } + + /// 0..1 when the watched task last reported determinate progress, + /// -1 while indeterminate or idle. + double progress() const + { + return m_progress; + } + + /** + * Minimum spacing between changed() emissions, in ms. Defaults to + * ~10/s. Exposed so tests do not have to wait on the real interval. + */ + void setMinIntervalMs(int ms) + { + m_minIntervalMs = ms; + } + + signals: + /** + * status() and/or progress() have (probably) changed. + * + * Coalesced to at most once per the configured interval while the + * watched task keeps reporting, so a fast-moving download does not + * turn into a redraw storm. The transition to idle (clear(), or the + * watched task finishing/dying) is never delayed by this. + */ + void changed(); + + private slots: + void onStatus(const QString& status); + void onProgress(qint64 current, qint64 total); + void onFinished(); + + private: + void stopWatching(); + void scheduleEmit(); + void emitNow(); + + Task* m_task = nullptr; + QVector m_connections; + QString m_status; + double m_progress = -1; + int m_minIntervalMs = 100; + QElapsedTimer m_sinceLastEmit; + QTimer* m_pendingTimer = nullptr; +}; diff --git a/launcher/launch/LaunchProgressTracker_test.cpp b/launcher/launch/LaunchProgressTracker_test.cpp new file mode 100644 index 00000000..47a0de06 --- /dev/null +++ b/launcher/launch/LaunchProgressTracker_test.cpp @@ -0,0 +1,218 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include + +#include "launch/LaunchProgressTracker.h" +#include "tasks/Task.h" + +namespace +{ + +/* Stands in for a real LaunchTask/launch step: nothing to execute, and + * Task::emitSucceeded()/emitFailed() forwarded to public wrappers so the + * test can drive the same lifecycle a real launch would, without needing an + * instance, launch steps, or a real launch. */ +class FakeTask : public Task +{ + Q_OBJECT + public: + using Task::Task; + + void succeed() + { + emitSucceeded(); + } + void fail(const QString& reason) + { + emitFailed(reason); + } + + protected: + void executeTask() override {} +}; + +} // namespace + +class LaunchProgressTrackerTest : public QObject +{ + Q_OBJECT + + private slots: + void test_idle_byDefault() + { + LaunchProgressTracker tracker; + QCOMPARE(tracker.status(), QString()); + QCOMPARE(tracker.progress(), -1.0); + } + + void test_watch_relaysStatusAndDeterminateProgress() + { + LaunchProgressTracker tracker; + tracker.setMinIntervalMs(0); + FakeTask task; + task.start(); + tracker.watch(&task); + + QSignalSpy changedSpy(&tracker, &LaunchProgressTracker::changed); + + task.setStatus("Downloading assets..."); + QVERIFY(!changedSpy.isEmpty()); + QCOMPARE(tracker.status(), QString("Downloading assets...")); + // No progress reported yet -- still indeterminate. + QCOMPARE(tracker.progress(), -1.0); + + task.setProgress(45, 100); + QCOMPARE(tracker.progress(), 0.45); + } + + void test_progress_isIndeterminate_whenTotalIsNotPositive() + { + LaunchProgressTracker tracker; + tracker.setMinIntervalMs(0); + FakeTask task; + task.start(); + tracker.watch(&task); + + task.setProgress(10, 0); + QCOMPARE(tracker.progress(), -1.0); + } + + void test_taskFinishing_goesBackToIdle() + { + LaunchProgressTracker tracker; + tracker.setMinIntervalMs(0); + FakeTask task; + task.start(); + tracker.watch(&task); + + task.setStatus("Downloading assets..."); + task.setProgress(50, 100); + QVERIFY(!tracker.status().isEmpty()); + + QSignalSpy changedSpy(&tracker, &LaunchProgressTracker::changed); + task.succeed(); + + QVERIFY(!changedSpy.isEmpty()); + QCOMPARE(tracker.status(), QString()); + QCOMPARE(tracker.progress(), -1.0); + } + + void test_taskFailing_alsoGoesBackToIdle() + { + LaunchProgressTracker tracker; + tracker.setMinIntervalMs(0); + FakeTask task; + task.start(); + tracker.watch(&task); + task.setStatus("Downloading assets..."); + + task.fail("network error"); + + QCOMPARE(tracker.status(), QString()); + QCOMPARE(tracker.progress(), -1.0); + } + + void test_watch_switchesToTheNewTask_ignoringTheOldOne() + { + LaunchProgressTracker tracker; + tracker.setMinIntervalMs(0); + FakeTask first; + first.start(); + tracker.watch(&first); + first.setStatus("First step"); + + FakeTask second; + second.start(); + tracker.watch(&second); + + // The old task no longer drives the tracker. + first.setStatus("Should be ignored"); + QCOMPARE(tracker.status(), QString("First step")); + + second.setStatus("Second step"); + QCOMPARE(tracker.status(), QString("Second step")); + } + + void test_watchedTaskDestroyed_goesBackToIdle() + { + LaunchProgressTracker tracker; + tracker.setMinIntervalMs(0); + auto* task = new FakeTask(); + task->start(); + tracker.watch(task); + task->setStatus("Downloading assets..."); + + delete task; + + QCOMPARE(tracker.status(), QString()); + QCOMPARE(tracker.progress(), -1.0); + } + + void test_clear_resetsToIdle_andAnnouncesItRightAway() + { + LaunchProgressTracker tracker; + tracker.setMinIntervalMs(1000); // long enough it can't fire on its own + FakeTask task; + task.start(); + tracker.watch(&task); + task.setStatus("Downloading assets..."); + + QSignalSpy changedSpy(&tracker, &LaunchProgressTracker::changed); + tracker.clear(); + + // Not coalesced, unlike an ordinary status/progress update. + QCOMPARE(changedSpy.count(), 1); + QCOMPARE(tracker.status(), QString()); + QCOMPARE(tracker.progress(), -1.0); + } + + /// The whole point of throttling: a burst of updates inside one + /// interval must not each produce their own changed() emission, but + /// the latest value is still visible immediately, and the coalesced + /// update is not simply dropped. + void test_rapidUpdates_areCoalesced() + { + LaunchProgressTracker tracker; + tracker.setMinIntervalMs(200); + FakeTask task; + task.start(); + tracker.watch(&task); + + QSignalSpy changedSpy(&tracker, &LaunchProgressTracker::changed); + for (int i = 1; i <= 20; ++i) { + task.setProgress(i, 20); + } + + // The first update in a quiet tracker is never throttled; the + // other 19 in the same burst should not each add their own. + QCOMPARE(changedSpy.count(), 1); + // ...but the latest value is visible right away regardless. + QCOMPARE(tracker.progress(), 1.0); + + // And the coalesced update is not lost - it shows up once the + // interval elapses. + QVERIFY(changedSpy.wait(2000)); + } +}; + +QTEST_GUILESS_MAIN(LaunchProgressTrackerTest) + +#include "LaunchProgressTracker_test.moc" diff --git a/launcher/launch/LaunchTask.cpp b/launcher/launch/LaunchTask.cpp index 8ce7fe20..bc476c88 100644 --- a/launcher/launch/LaunchTask.cpp +++ b/launcher/launch/LaunchTask.cpp @@ -82,7 +82,7 @@ void LaunchTask::onStepFinished() // initial -> just start the first step if (currentStep == -1) { currentStep++; - m_steps[currentStep]->start(); + startStep(m_steps[currentStep]); return; } @@ -93,14 +93,27 @@ void LaunchTask::onStepFinished() finalizeSteps(true, QString()); } else { currentStep++; - step = m_steps[currentStep]; - step->start(); + startStep(m_steps[currentStep]); } } else { finalizeSteps(false, step->failReason()); } } +void LaunchTask::startStep(const shared_qobject_ptr& step) +{ + // Only ever forwarding one step at a time - drop the previous one + // before wiring the new one, rather than leaving it to fire into a + // step that has already moved on. + QObject::disconnect(m_stepStatusConnection); + QObject::disconnect(m_stepProgressConnection); + m_stepStatusConnection = + connect(step.get(), &Task::status, this, &Task::setStatus); + m_stepProgressConnection = + connect(step.get(), &Task::progress, this, &Task::setProgress); + step->start(); +} + void LaunchTask::finalizeSteps(bool successful, const QString& error) { for (auto step = currentStep; step >= 0; step--) { diff --git a/launcher/launch/LaunchTask.h b/launcher/launch/LaunchTask.h index aef02eb4..77935b77 100644 --- a/launcher/launch/LaunchTask.h +++ b/launcher/launch/LaunchTask.h @@ -126,6 +126,15 @@ class LaunchTask : public Task private: /*methods */ void finalizeSteps(bool successful, const QString& error); + /* Start @p step and, for as long as it runs, relay its status()/ + * progress() onto our own - the same way Update relays the update + * task it wraps onto itself. This is what lets something outside the + * step list (InstanceList's launch progress tracking, in particular) + * watch the launch as a whole instead of having to know which step + * is currently running. + */ + void startStep(const shared_qobject_ptr& step); + protected: /* data */ InstancePtr m_instance; shared_qobject_ptr m_logModel; @@ -135,4 +144,8 @@ class LaunchTask : public Task int currentStep = -1; State state = NotStarted; qint64 m_pid = -1; + + private: /* data */ + QMetaObject::Connection m_stepStatusConnection; + QMetaObject::Connection m_stepProgressConnection; }; diff --git a/launcher/main.cpp b/launcher/main.cpp index f4739540..e39a68d9 100644 --- a/launcher/main.cpp +++ b/launcher/main.cpp @@ -20,15 +20,35 @@ #include "Application.h" #include "BuildConfig.h" #include "FileSystem.h" +#include "settings/Setting.h" +#include "settings/SettingsObject.h" #include -#include #include -#include +#include #include -#ifndef Q_OS_WIN +#include +#include +#include +#ifdef Q_OS_WIN +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#else +#include #include +/* Not declared by every platform's (notably not by macOS's - + * see environ(7): "the following line placed in the source file should be + * sufficient to obtain the definition"). Declared here rather than inside a + * function or an unnamed namespace, both of which would give this specific + * declaration internal linkage and quietly fail to bind to the real, + * externally-linked libc symbol. */ +extern char** environ; #endif // #define BREAK_INFINITE_LOOP @@ -40,49 +60,242 @@ #include #endif -static void launchCrashReporter() +namespace { - // Locate the crash reporter binary next to the running executable. - QString crashReporterName = "meshmc-crashreporter"; + /* + * Async-signal-safe crash reporter launch. + * + * launchCrashReporter() used to build its argument list and spawn the + * reporter process from inside crashSignalHandler() itself, using + * QFile::exists(), APPLICATION->settings(), QDir::currentPath(), + * logFile->flush() and QProcess::startDetached() - none of which are + * async-signal-safe, and a crash can land the handler on any thread, at + * any point in the middle of Qt's or the allocator's own state. + * Everything the reporter needs is instead computed once, up front + * (rebuildCrashReporterInvocation(), called after Application exists and + * again whenever PasteEEAPIKey changes); the handler only reads the + * already-built, plain-old-data invocation and calls the OS + * process-creation primitive directly - CreateProcessW on Windows, + * posix_spawn elsewhere - neither of which touches Qt, the heap, or the + * log file. + * + * DOUBLE BUFFERING. Two CrashReporterInvocation slots + * (g_invocationStorage) and an atomic index (g_activeInvocation) into + * them: a rebuild always fills the slot that is *not* currently active, + * then publishes it with a single atomic store. A signal landing + * mid-rebuild therefore always reads either the old, complete invocation + * or the new, complete one - never a half-written one. -1 means nothing + * has been built yet (matches the original's silent no-op when the + * reporter binary was not found). + */ + constexpr int kMaxPathChars = 1024; + constexpr int kMaxArgChars = 512; #ifdef Q_OS_WIN - crashReporterName += ".exe"; + // The full command line (quoted path + three quoted "--flag value" + // pairs). quoteWindowsArg() below can at most double an argument's + // length (a run of backslashes right before a quote, or at the end of + // the argument, doubles) plus one extra character per embedded quote, + // so this doubles the previous, unescaped budget for headroom. + constexpr int kMaxCommandLineChars = + 2 * (kMaxPathChars + 3 * kMaxArgChars) + 64; #endif - QString crashReporterPath = - FS::PathCombine(QApplication::applicationDirPath(), crashReporterName); - if (!QFile::exists(crashReporterPath)) { - return; - } + struct CrashReporterInvocation { + bool valid = false; +#ifdef Q_OS_WIN + wchar_t path[kMaxPathChars] = {}; + // CreateProcessW's lpCommandLine must be writable memory (it may + // rewrite separators internally); never a literal or a const + // buffer. + wchar_t commandLine[kMaxCommandLineChars] = {}; +#else + char path[kMaxPathChars] = {}; + char logDirArg[kMaxArgChars] = {}; + char nameArg[kMaxArgChars] = {}; + char apiKeyArg[kMaxArgChars] = {}; + // Pointers into the members above (plus two string-literal flags), + // rebuilt in lock-step with them so they always describe this same + // slot's strings. + char* argv[8] = {}; +#endif + }; - QStringList args; - args << "--logdir" << QDir::currentPath(); - args << "--name" << BuildConfig.MESHMC_NAME; - - QString apiKey = "public"; - if (APPLICATION && APPLICATION->settings()) { - QString key = APPLICATION->settings()->get("PasteEEAPIKey").toString(); - if (key != "meshmc" && !key.isEmpty()) { - apiKey = key; - } else { - apiKey = BuildConfig.PASTE_EE_KEY; + CrashReporterInvocation g_invocationStorage[2]; + std::atomic g_activeInvocation{-1}; + +#ifdef Q_OS_WIN + bool copyToBuffer(wchar_t* dest, int destCharCount, const std::wstring& src) + { + if (static_cast(src.size()) >= destCharCount) { + return false; } + std::memcpy(dest, src.c_str(), (src.size() + 1) * sizeof(wchar_t)); + return true; } - args << "--apikey" << apiKey; - // Flush the log file before launching the crash reporter - if (APPLICATION && APPLICATION->logFile) { - APPLICATION->logFile->flush(); + /* Quotes and escapes a single argument for CreateProcessW's + * lpCommandLine, following the same backslash/quote rules + * CommandLineToArgvW uses to parse it back apart: a backslash is only + * special when it immediately precedes a double quote (an embedded one, + * or the closing quote this function appends) - a run of N backslashes + * there must become 2N to stay literal, plus one more to escape the + * quote itself; everywhere else a backslash is an ordinary character. + * This matters because apiKey comes straight from the user-editable + * PasteEEAPIKey setting - arbitrary text that may contain a '"' - and + * unlike the POSIX branch's explicit argv[], CreateProcessW re-parses a + * single command-line string, so an unescaped quote would let it inject + * an extra argument into meshmc-crashreporter's parsed argv. */ + QString quoteWindowsArg(const QString& arg) + { + QString result = QStringLiteral("\""); + int backslashes = 0; + for (const QChar& ch : arg) { + if (ch == QLatin1Char('\\')) { + ++backslashes; + continue; + } + if (ch == QLatin1Char('"')) { + result += QString(backslashes * 2 + 1, QLatin1Char('\\')); + backslashes = 0; + result += ch; + continue; + } + result += QString(backslashes, QLatin1Char('\\')); + backslashes = 0; + result += ch; + } + // Trailing backslashes must be doubled so they escape themselves + // rather than the closing quote appended below. + result += QString(backslashes * 2, QLatin1Char('\\')); + result += QLatin1Char('"'); + return result; + } +#else + bool copyToBuffer(char* dest, int destCharCount, const QByteArray& src) + { + if (src.size() >= destCharCount) { + return false; + } + std::memcpy(dest, src.constData(), static_cast(src.size())); + dest[src.size()] = '\0'; + return true; } +#endif - QProcess::startDetached(crashReporterPath, args); -} + /* Rebuilds the currently-inactive slot from scratch and publishes it - + * see the class comment above. GUI thread only: called once at startup + * (after Application, and therefore its settings, exist) and again + * whenever the PasteEEAPIKey setting changes. */ + void rebuildCrashReporterInvocation() + { + QString crashReporterName = QStringLiteral("meshmc-crashreporter"); +#ifdef Q_OS_WIN + crashReporterName += QStringLiteral(".exe"); +#endif + const QString path = FS::PathCombine( + QApplication::applicationDirPath(), crashReporterName); + if (!QFile::exists(path)) { + // Nothing to launch - same early return the original had. Any + // previously-published invocation (e.g. from before the binary + // was removed, which should not normally happen) is left alone + // rather than invalidated, since a stale-but-valid invocation is + // safer to fall back on than none at all. + return; + } + + QString apiKey = QStringLiteral("public"); + if (APPLICATION && APPLICATION->settings()) { + const QString key = + APPLICATION->settings()->get("PasteEEAPIKey").toString(); + apiKey = (key != QLatin1String("meshmc") && !key.isEmpty()) + ? key + : BuildConfig.PASTE_EE_KEY; + } + const QString logDir = QDir::currentPath(); + const QString name = BuildConfig.MESHMC_NAME; + + const int current = g_activeInvocation.load(std::memory_order_relaxed); + const int nextSlot = current == 0 ? 1 : 0; + CrashReporterInvocation& slot = g_invocationStorage[nextSlot]; + slot.valid = false; + +#ifdef Q_OS_WIN + if (!copyToBuffer(slot.path, kMaxPathChars, path.toStdWString())) { + return; + } + // Conventionally, the command line's own first token is the module + // path too (a child reads that back via GetCommandLine()). apiKey + // in particular is arbitrary, user-editable text (the PasteEEAPIKey + // setting), so every argument is escaped with quoteWindowsArg() + // rather than just wrapped in literal quotes - see its comment. + const QString commandLine = + QStringLiteral("%1 --logdir %2 --name %3 --apikey %4") + .arg(quoteWindowsArg(path), quoteWindowsArg(logDir), + quoteWindowsArg(name), quoteWindowsArg(apiKey)); + if (!copyToBuffer(slot.commandLine, kMaxCommandLineChars, + commandLine.toStdWString())) { + return; + } +#else + if (!copyToBuffer(slot.path, kMaxPathChars, path.toLocal8Bit())) { + return; + } + if (!copyToBuffer(slot.logDirArg, kMaxArgChars, logDir.toLocal8Bit())) { + return; + } + if (!copyToBuffer(slot.nameArg, kMaxArgChars, name.toLocal8Bit())) { + return; + } + if (!copyToBuffer(slot.apiKeyArg, kMaxArgChars, apiKey.toLocal8Bit())) { + return; + } + int i = 0; + slot.argv[i++] = slot.path; + slot.argv[i++] = const_cast("--logdir"); + slot.argv[i++] = slot.logDirArg; + slot.argv[i++] = const_cast("--name"); + slot.argv[i++] = slot.nameArg; + slot.argv[i++] = const_cast("--apikey"); + slot.argv[i++] = slot.apiKeyArg; + slot.argv[i] = nullptr; +#endif + + slot.valid = true; + // release: everything written to `slot` above must be visible to + // whichever thread's signal handler next acquire-loads this index. + g_activeInvocation.store(nextSlot, std::memory_order_release); + } +} // namespace static void crashSignalHandler(int sig) { // Re-set default handler to avoid infinite loops signal(sig, SIG_DFL); - launchCrashReporter(); + const int slot = g_activeInvocation.load(std::memory_order_acquire); + if (slot >= 0) { + CrashReporterInvocation& invocation = g_invocationStorage[slot]; + if (invocation.valid) { +#ifdef Q_OS_WIN + STARTUPINFOW startupInfo; + std::memset(&startupInfo, 0, sizeof(startupInfo)); + startupInfo.cb = sizeof(startupInfo); + PROCESS_INFORMATION processInfo; + std::memset(&processInfo, 0, sizeof(processInfo)); + if (CreateProcessW(invocation.path, invocation.commandLine, + nullptr, nullptr, FALSE, DETACHED_PROCESS, + nullptr, nullptr, &startupInfo, + &processInfo)) { + CloseHandle(processInfo.hProcess); + CloseHandle(processInfo.hThread); + } +#else + pid_t childPid = 0; + posix_spawn(&childPid, invocation.path, nullptr, nullptr, + invocation.argv, environ); +#endif + } + } // Re-raise the signal so the default handler produces a core dump etc. raise(sig); @@ -136,6 +349,22 @@ int main(int argc, char* argv[]) Application app(argc, argv); + // Build the crash reporter invocation the signal handler below will use, + // and keep it current: a crash right after the user edits the PasteEE + // key in Settings should still upload with the new one. See + // rebuildCrashReporterInvocation()'s comment for why this cannot simply + // be recomputed inside the handler itself. + rebuildCrashReporterInvocation(); + if (app.settings()) { + QObject::connect(app.settings().get(), &SettingsObject::SettingChanged, + &app, [](const Setting& setting, const QVariant&) { + if (setting.id() == + QLatin1String("PasteEEAPIKey")) { + rebuildCrashReporterInvocation(); + } + }); + } + // Install crash signal handlers to launch meshmc-crashreporter signal(SIGSEGV, crashSignalHandler); signal(SIGABRT, crashSignalHandler); diff --git a/launcher/meta/BaseEntity.cpp b/launcher/meta/BaseEntity.cpp index 644cd31a..2871ed63 100644 --- a/launcher/meta/BaseEntity.cpp +++ b/launcher/meta/BaseEntity.cpp @@ -27,7 +27,7 @@ #include "Json.h" #include "BuildConfig.h" -#include "Application.h" +#include "core/LauncherContext.h" class ParsingValidator : public Net::Validator { @@ -110,10 +110,10 @@ void Meta::BaseEntity::load(Net::Mode loadType) } m_updateTask = new NetJob(QObject::tr("Download of meta file %1").arg(localFilename()), - APPLICATION->network()); + LAUNCHER->network()); auto url = this->url(); auto entry = - APPLICATION->metacache()->resolveEntry("meta", localFilename()); + LAUNCHER->metacache()->resolveEntry("meta", localFilename()); entry->setStale(true); auto dl = Net::Download::makeCached(url, entry); /* diff --git a/launcher/minecraft/AssetsUtils.cpp b/launcher/minecraft/AssetsUtils.cpp index cb21deeb..0587a2c7 100644 --- a/launcher/minecraft/AssetsUtils.cpp +++ b/launcher/minecraft/AssetsUtils.cpp @@ -34,7 +34,7 @@ #include "net/ChecksumValidator.h" #include "BuildConfig.h" -#include "Application.h" +#include "core/LauncherContext.h" namespace { @@ -313,7 +313,7 @@ QString AssetObject::getRelPath() NetJob::Ptr AssetsIndex::getDownloadJob() { auto job = new NetJob(QObject::tr("Assets for %1").arg(id), - APPLICATION->network()); + LAUNCHER->network()); for (auto& object : objects.values()) { auto dl = object.getDownloadAction(); if (dl) { diff --git a/launcher/minecraft/Component.cpp b/launcher/minecraft/Component.cpp index 424e81eb..de5051cb 100644 --- a/launcher/minecraft/Component.cpp +++ b/launcher/minecraft/Component.cpp @@ -28,7 +28,7 @@ #include "minecraft/PackProfile.h" #include "FileSystem.h" #include "OneSixVersionFormat.h" -#include "Application.h" +#include "core/LauncherContext.h" #include @@ -172,8 +172,8 @@ std::shared_ptr Component::getVersionFile() const std::shared_ptr Component::getVersionList() const { // FIXME: what if the metadata index isn't loaded yet? - if (APPLICATION->metadataIndex()->hasUid(m_uid)) { - return APPLICATION->metadataIndex()->get(m_uid); + if (LAUNCHER->metadataIndex()->hasUid(m_uid)) { + return LAUNCHER->metadataIndex()->get(m_uid); } return nullptr; } @@ -270,7 +270,7 @@ bool Component::isRemovable() bool Component::isRevertible() { if (isCustom()) { - if (APPLICATION->metadataIndex()->hasUid(m_uid)) { + if (LAUNCHER->metadataIndex()->hasUid(m_uid)) { return true; } } @@ -336,7 +336,7 @@ void Component::setVersion(const QString& version) m_cachedVersion = version; // see if the meta version is loaded auto metaVersion = - APPLICATION->metadataIndex()->get(m_uid, version); + LAUNCHER->metadataIndex()->get(m_uid, version); if (metaVersion->isLoaded()) { // if yes, we can continue with that. m_metaVersion = metaVersion; @@ -404,7 +404,7 @@ bool Component::revert() m_file.reset(); // check local cache for metadata... - auto version = APPLICATION->metadataIndex()->get(m_uid, m_version); + auto version = LAUNCHER->metadataIndex()->get(m_uid, m_version); if (version->isLoaded()) { m_metaVersion = version; } else { diff --git a/launcher/minecraft/ComponentUpdateTask.cpp b/launcher/minecraft/ComponentUpdateTask.cpp index c1da1d08..b55d4790 100644 --- a/launcher/minecraft/ComponentUpdateTask.cpp +++ b/launcher/minecraft/ComponentUpdateTask.cpp @@ -31,7 +31,7 @@ #include "net/Mode.h" #include "OneSixVersionFormat.h" -#include "Application.h" +#include "core/LauncherContext.h" /* * This is responsible for loading the components of a component list AND @@ -115,7 +115,7 @@ namespace component->m_loaded = true; result = LoadResult::LoadedLocal; } else { - auto metaVersion = APPLICATION->metadataIndex()->get( + auto metaVersion = LAUNCHER->metadataIndex()->get( component->m_uid, component->m_version); component->m_metaVersion = metaVersion; if (metaVersion->isLoaded()) { @@ -166,12 +166,12 @@ namespace static LoadResult loadIndex(Task::Ptr& loadTask, Net::Mode netmode) { // FIXME: DECIDE. do we want to run the update task anyway? - if (APPLICATION->metadataIndex()->isLoaded()) { + if (LAUNCHER->metadataIndex()->isLoaded()) { qDebug() << "Index is already loaded"; return LoadResult::LoadedLocal; } - APPLICATION->metadataIndex()->load(netmode); - loadTask = APPLICATION->metadataIndex()->getCurrentTask(); + LAUNCHER->metadataIndex()->load(netmode); + loadTask = LAUNCHER->metadataIndex()->getCurrentTask(); if (loadTask) { return LoadResult::RequiresRemote; } diff --git a/launcher/minecraft/MinecraftInstance.cpp b/launcher/minecraft/MinecraftInstance.cpp index f4317e22..86acb9b7 100644 --- a/launcher/minecraft/MinecraftInstance.cpp +++ b/launcher/minecraft/MinecraftInstance.cpp @@ -23,7 +23,7 @@ #include "minecraft/launch/PrintInstanceInfo.h" #include "settings/Setting.h" #include "settings/SettingsObject.h" -#include "Application.h" +#include "core/LauncherContext.h" #include #include "MMCStrings.h" @@ -242,6 +242,14 @@ MinecraftInstance::MinecraftInstance(SettingsObjectPtr globalSettings, m_components->setOldConfigVersion( "com.mumfrey.liteloader", m_settings->get("LiteloaderVersion").toString()); + + /* Refreshes anything reading gameVersion()/modLoaderName() - the QML + * instance list, in particular - when the profile changes underneath + * it. minecraftChanged() already exists for this (VersionPage listens + * to the same signal to refresh itself); propertiesChanged() is what + * InstanceList already listens to for every instance. */ + connect(m_components.get(), &PackProfile::minecraftChanged, this, + [this] { emit propertiesChanged(this); }); } void MinecraftInstance::saveNow() @@ -293,6 +301,32 @@ QString MinecraftInstance::minecraftVersion() const return components->getComponentVersion("net.minecraft"); } +QString MinecraftInstance::modLoaderName() const +{ + auto components = getPackProfile(); + if (!components) { + return QString(); + } + + /* Piggybacks on minecraftVersion()'s lazy load instead of reloading a + * second time: once it has run, the profile is either loaded or the + * load failed, and either way there is nothing more a second attempt + * here would achieve. */ + minecraftVersion(); + + const int rows = components->rowCount(); + for (int i = 0; i < rows; ++i) { + Component* component = components->getComponent(i); + if (!component || !component->isEnabled()) { + continue; + } + if (const ModLoaderInfo* loader = modLoaderForUid(component->getID())) { + return loader->brandName; + } + } + return QString(); +} + bool MinecraftInstance::supportsDemo() const { const QString version = minecraftVersion(); @@ -992,7 +1026,7 @@ MinecraftInstance::createLaunchTask(AuthSessionPtr session, std::dynamic_pointer_cast(shared_from_this())); auto pptr = process.get(); - APPLICATION->icons()->saveIcon( + LAUNCHER->icons()->saveIcon( iconKey(), FS::PathCombine(gameRoot(), "icon.png"), "PNG"); // print a header diff --git a/launcher/minecraft/MinecraftInstance.h b/launcher/minecraft/MinecraftInstance.h index 2f2f8093..89932c2f 100644 --- a/launcher/minecraft/MinecraftInstance.h +++ b/launcher/minecraft/MinecraftInstance.h @@ -68,6 +68,19 @@ class MinecraftInstance : public BaseInstance */ QString minecraftVersion() const; + /// BaseInstance::gameVersion() override - same value as minecraftVersion(). + QString gameVersion() const override + { + return minecraftVersion(); + } + + /** + * Human name of this instance's mod loader ("Fabric", "Forge", ...), + * or empty if none of the loaders this launcher knows about is + * installed and enabled. See BaseInstance::modLoaderName(). + */ + QString modLoaderName() const override; + /** * Whether this instance's Minecraft version understands --demo. * diff --git a/launcher/minecraft/PackProfile.cpp b/launcher/minecraft/PackProfile.cpp index 24d9e0e6..752fbefa 100644 --- a/launcher/minecraft/PackProfile.cpp +++ b/launcher/minecraft/PackProfile.cpp @@ -39,8 +39,9 @@ #include "PackProfile.h" #include "PackProfile_p.h" #include "ComponentUpdateTask.h" +#include "tasks/TaskWatcher.h" -#include "Application.h" +#include "core/LauncherContext.h" PackProfile::PackProfile(MinecraftInstance* instance) : QAbstractListModel() { @@ -355,6 +356,14 @@ void PackProfile::resolve(Net::Mode netmode) &PackProfile::updateSucceeded); connect(updateTask, &ComponentUpdateTask::failed, this, &PackProfile::updateFailed); + + /* QML-facing progress for whichever action just called resolve() - + * change/install a loader, remove a conflicting one, reload. Not + * deleted: see the `task` Q_PROPERTY comment in the header. */ + m_taskWatcher = new TaskWatcher(d->m_updateTask, this); + m_taskWatcher->setTitle(tr("Updating %1").arg(d->m_instance->name())); + emit taskChanged(); + d->m_updateTask->start(); } @@ -372,6 +381,7 @@ void PackProfile::updateFailed(const QString& error) << d->m_instance->name() << "Reason:" << error; d->m_updateTask.reset(); invalidateLaunchProfile(); + setLastError(error); } // NOTE this is really old stuff, and only needs to be used when loading the old @@ -481,7 +491,7 @@ bool PackProfile::migratePreComponentConfig() component->m_version = intendedVersion; } else if (!intendedVersion.isEmpty()) { auto metaVersion = - APPLICATION->metadataIndex()->get(uid, intendedVersion); + LAUNCHER->metadataIndex()->get(uid, intendedVersion); component = new Component(this, metaVersion); } else { return; @@ -543,7 +553,7 @@ bool PackProfile::migratePreComponentConfig() auto patchVersion = d->getOldConfigVersion(uid); if (!patchVersion.isEmpty() && !loadedComponents.contains(uid)) { auto patch = new Component( - this, APPLICATION->metadataIndex()->get(uid, patchVersion)); + this, LAUNCHER->metadataIndex()->get(uid, patchVersion)); patch->setOrder(order); loadedComponents[uid] = patch; } @@ -717,6 +727,65 @@ bool PackProfile::revertToBase(int index) return true; } +bool PackProfile::removeComponent(int row) +{ + auto* patch = getComponent(row); + if (!patch) { + setLastError(tr("No such component.")); + return false; + } + if (!remove(row)) { + setLastError(tr("Couldn't remove %1.").arg(patch->getName())); + return false; + } + setLastError(QString()); + return true; +} + +bool PackProfile::moveComponentUp(int row) +{ + auto* patch = getComponent(row); + if (!patch || !patch->isMoveable()) { + setLastError(tr("Can't move this component.")); + return false; + } + move(row, MoveUp); + setLastError(QString()); + return true; +} + +bool PackProfile::moveComponentDown(int row) +{ + auto* patch = getComponent(row); + if (!patch || !patch->isMoveable()) { + setLastError(tr("Can't move this component.")); + return false; + } + move(row, MoveDown); + setLastError(QString()); + return true; +} + +bool PackProfile::customizeComponent(int row) +{ + if (!customize(row)) { + setLastError(tr("Couldn't customize this component.")); + return false; + } + setLastError(QString()); + return true; +} + +bool PackProfile::revertComponent(int row) +{ + if (!revertToBase(row)) { + setLastError(tr("Couldn't revert this component.")); + return false; + } + setLastError(QString()); + return true; +} + Component* PackProfile::getComponent(const QString& id) { auto iter = d->componentIndex.find(id); @@ -812,6 +881,28 @@ QVariant PackProfile::data(const QModelIndex& index, int role) const return QVariant(); } } + // Everything below is for VersionTab.qml - one role per question + // VersionPage's updateButtons()/on_action..._triggered() handlers + // ask about the selected row, so the QML tab can enable/disable + // its own per-row actions without a round trip to C++. + case UidRole: + return patch->getID(); + case IsCustomRole: + return patch->isCustom(); + case IsEnabledRole: + return patch->isEnabled(); + case CanDisableRole: + return patch->canBeDisabled(); + case IsRemovableRole: + return patch->isRemovable(); + case IsMoveableRole: + return patch->isMoveable(); + case IsCustomizableRole: + return patch->isCustomizable(); + case IsRevertibleRole: + return patch->isRevertible(); + case HasVersionListRole: + return patch->getVersionList() != nullptr; } return QVariant(); } @@ -889,6 +980,15 @@ QHash PackProfile::roleNames() const roles.insert(NameRole, "name"); roles.insert(VersionRole, "version"); roles.insert(ProblemSeverityRole, "problemSeverity"); + roles.insert(UidRole, "uid"); + roles.insert(IsCustomRole, "isCustom"); + roles.insert(IsEnabledRole, "isEnabled"); + roles.insert(CanDisableRole, "canDisable"); + roles.insert(IsRemovableRole, "isRemovable"); + roles.insert(IsMoveableRole, "isMoveable"); + roles.insert(IsCustomizableRole, "isCustomizable"); + roles.insert(IsRevertibleRole, "isRevertible"); + roles.insert(HasVersionListRole, "hasVersionList"); return roles; } @@ -1192,6 +1292,86 @@ QString PackProfile::getComponentVersion(const QString& uid) const return QString(); } +bool PackProfile::changeComponentVersion(const QString& uid, + const QString& version) +{ + if (uid.isEmpty() || version.isEmpty()) { + setLastError(tr("No version selected.")); + return false; + } + if (d->m_updateTask) { + setLastError(tr("Already updating - wait for that to finish first.")); + return false; + } + // Mirrors VersionPage::on_actionChange_version_triggered(): only the + // Minecraft component's version is ever `important`. + const bool important = uid == QStringLiteral("net.minecraft"); + if (!setComponentVersion(uid, version, important)) { + setLastError(tr("Couldn't set %1 to that version.").arg(uid)); + return false; + } + setLastError(QString()); + resolve(Net::Mode::Online); + return true; +} + +bool PackProfile::setComponentEnabled(const QString& uid, bool enabled) +{ + Component* component = getComponent(uid); + if (!component) { + setLastError(tr("%1 isn't installed.").arg(uid)); + return false; + } + if (component->isEnabled() == enabled) { + return true; + } + if (!component->canBeDisabled()) { + setLastError(tr("%1 can't be turned off.").arg(component->getName())); + return false; + } + component->setEnabled(enabled); + setLastError(QString()); + return true; +} + +bool PackProfile::reloadProfile() +{ + if (d->m_updateTask) { + // Mirrors reload()'s own guard: an update is already in control. + return false; + } + try { + reload(Net::Mode::Online); + } catch (const Exception& e) { + setLastError(e.cause()); + return false; + } catch (...) { + setLastError(tr("Couldn't reload the instance profile.")); + return false; + } + setLastError(QString()); + return true; +} + +QObject* PackProfile::task() const +{ + return m_taskWatcher; +} + +bool PackProfile::busy() const +{ + return d->m_updateTask != nullptr; +} + +void PackProfile::setLastError(const QString& error) +{ + if (m_lastError == error) { + return; + } + m_lastError = error; + emit lastErrorChanged(); +} + QStringList PackProfile::getModLoaders() { QStringList result; diff --git a/launcher/minecraft/PackProfile.h b/launcher/minecraft/PackProfile.h index 6b165455..6bfa66a3 100644 --- a/launcher/minecraft/PackProfile.h +++ b/launcher/minecraft/PackProfile.h @@ -37,18 +37,65 @@ class MinecraftInstance; struct PackProfileData; class ComponentUpdateTask; +class TaskWatcher; class PackProfile : public QAbstractListModel { Q_OBJECT friend ComponentUpdateTask; + /// The ComponentUpdateTask behind the last reload()/resolve() call + /// (see those and changeComponentVersion()/setComponentEnabled() + /// below, which route through resolve()), wrapped for QML - see + /// TaskWatcher's own class comment. Null until the first one runs; + /// past that, always the most recent one, whether it is still + /// running or has already finished. Parented to `this`, like + /// ContentBrowser::install()'s own watcher, so QML need not manage + /// its lifetime. + Q_PROPERTY(QObject* task READ task NOTIFY taskChanged) + /// Whether a ComponentUpdateTask is currently running - QML's cue to + /// disable the Version tab's actions the same way VersionPage's + /// `controlsEnabled` did while running. + Q_PROPERTY(bool busy READ busy NOTIFY taskChanged) + /// The reason the last mutating call below failed, or empty. Cleared + /// on the next attempt, successful or not. + Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged) + public: enum Columns { NameColumn = 0, VersionColumn, NUM_COLUMNS }; // Column-independent roles for QML consumers. Kept separate from // Columns above, which the QtWidgets tree view still relies on. - enum ModelRoles { NameRole = Qt::UserRole, VersionRole, ProblemSeverityRole }; + // + // Appended after ProblemSeverityRole, not interleaved, so the three + // original values never change - see VersionTab.qml for the QML + // Version tab these back. + enum ModelRoles { + NameRole = Qt::UserRole, + VersionRole, + ProblemSeverityRole, + UidRole, + IsCustomRole, + IsEnabledRole, + CanDisableRole, + IsRemovableRole, + IsMoveableRole, + IsCustomizableRole, + IsRevertibleRole, + /// Whether the metadata index knows a version list for this + /// component's uid at all - the QML "Change version" action is + /// gated on this rather than on Component::isVersionChangeable(), + /// which would have to call Meta::VersionList::load() to answer + /// precisely (list loaded and non-empty) and + /// Meta::BaseEntity::load() starts a fresh download on every call + /// that is not already in flight (see LoaderVersionPage::reload()'s + /// own comment on this) - doing that from data(), read once per + /// visible row on every relayout, would hammer the metadata + /// server. A uid with a list that turns out empty once the picker + /// actually loads it shows that picker's own empty state instead + /// (see VersionTab.qml). + HasVersionListRole + }; explicit PackProfile(MinecraftInstance* instance); virtual ~PackProfile(); @@ -104,11 +151,60 @@ class PackProfile : public QAbstractListModel // from instance config void setOldConfigVersion(const QString& uid, const QString& version); - QString getComponentVersion(const QString& uid) const; + Q_INVOKABLE QString getComponentVersion(const QString& uid) const; bool setComponentVersion(const QString& uid, const QString& version, bool important = false); + /// QML entry point for VersionPage's "change version"/"install loader" + /// actions: setComponentVersion() (uid == "net.minecraft" is always + /// `important`, matching VersionPage::on_actionChange_version_triggered()) + /// followed by resolve(Net::Mode::Online), the same two steps the + /// widget dialogs perform on accept. Returns false, and sets + /// lastError(), when @p uid or @p version is empty; resolve() itself + /// reports its own failure asynchronously through task()/lastError(). + Q_INVOKABLE bool changeComponentVersion(const QString& uid, + const QString& version); + + /// QML entry point for a component's on/off switch, and for + /// LoaderInstaller's conflict handling (turning an existing, clashing + /// loader off before installing a new one) - Component::setEnabled() + /// itself is not QML-reachable (Component is not exposed to QML on + /// its own). Returns false if @p uid names no component or the + /// component refuses (canBeDisabled() is false). + Q_INVOKABLE bool setComponentEnabled(const QString& uid, bool enabled); + + /// QML entry point for VersionPage's remove/move/customize/revert + /// toolbar actions, by row rather than by the model index QML would + /// otherwise have to build. Each mirrors the matching VersionPage + /// `on_action..._triggered()` handler, including the + /// invalidateLaunchProfile()/scheduleSave() those already do + /// internally; unlike the widget page, reloadPackProfile() is not + /// re-run afterwards - remove()/move()/customize()/revertToBase() + /// already update this model's rows in place. Each returns false + /// (and sets lastError()) when @p row is out of range or the + /// component refuses the operation (not removable/moveable/etc). + Q_INVOKABLE bool removeComponent(int row); + Q_INVOKABLE bool moveComponentUp(int row); + Q_INVOKABLE bool moveComponentDown(int row); + Q_INVOKABLE bool customizeComponent(int row); + Q_INVOKABLE bool revertComponent(int row); + + /// Re-reads mmc-pack.json/patches from disk and resolves the result, + /// the same as VersionPage's "Reload" action - see reload() above, + /// which this simply calls with Net::Mode::Online and reports + /// failure from through lastError(). + Q_INVOKABLE bool reloadProfile(); + + /// Defined in the .cpp, where TaskWatcher (forward-declared above) is + /// a complete type. + QObject* task() const; + bool busy() const; + QString lastError() const + { + return m_lastError; + } + bool installEmpty(const QString& uid, const QString& name); QString patchFilePathForUid(const QString& uid) const; @@ -142,6 +238,8 @@ class PackProfile : public QAbstractListModel signals: void minecraftChanged(); + void taskChanged(); + void lastErrorChanged(); public: /// get the profile component by id @@ -184,6 +282,17 @@ class PackProfile : public QAbstractListModel bool migratePreComponentConfig(); + /// Sets m_lastError and emits lastErrorChanged() - the one place every + /// QML-facing wrapper above reports a failure through, so lastError() + /// is never left holding a stale reason from an unrelated call. + void setLastError(const QString& error); + private: /* data */ std::unique_ptr d; + + /// See the `task` Q_PROPERTY above. Parented to `this`; not deleted + /// on replacement (ContentBrowser::install()'s watchers are kept the + /// same way) - only PackProfile's own destruction cleans it up. + TaskWatcher* m_taskWatcher = nullptr; + QString m_lastError; }; diff --git a/launcher/minecraft/auth/AccountList.cpp b/launcher/minecraft/auth/AccountList.cpp index c936c04b..74c3d0ee 100644 --- a/launcher/minecraft/auth/AccountList.cpp +++ b/launcher/minecraft/auth/AccountList.cpp @@ -337,6 +337,30 @@ static QString accountStateDisplayString(AccountState state) return QString(); } +/* Stable, non-localized counterpart to accountStateDisplayString() above, + * for QML's StateKeyRole -- code that wants to branch on the state (to pick + * a color, say) should not have to match translated text. */ +static QString accountStateKey(AccountState state) +{ + switch (state) { + case AccountState::Unchecked: + return QStringLiteral("unchecked"); + case AccountState::Offline: + return QStringLiteral("offline"); + case AccountState::Online: + return QStringLiteral("online"); + case AccountState::Working: + return QStringLiteral("working"); + case AccountState::Errored: + return QStringLiteral("errored"); + case AccountState::Expired: + return QStringLiteral("expired"); + case AccountState::Gone: + return QStringLiteral("gone"); + } + return QString(); +} + QVariant AccountList::data(const QModelIndex& index, int role) const { if (!index.isValid()) @@ -404,6 +428,15 @@ QVariant AccountList::data(const QModelIndex& index, int role) const } case StatusRole: return accountStateDisplayString(account->accountState()); + case IsDefaultRole: + return account == m_defaultAccount; + case IsMSARole: + return account->isMSA(); + case StateKeyRole: + return accountStateKey(account->accountState()); + case AccountIdRole: + return account->profileId().isEmpty() ? account->internalId() + : account->profileId(); default: return QVariant(); @@ -417,6 +450,10 @@ QHash AccountList::roleNames() const roles.insert(ProfileNameRole, "profileName"); roles.insert(TypeRole, "type"); roles.insert(StatusRole, "status"); + roles.insert(IsDefaultRole, "isDefault"); + roles.insert(IsMSARole, "isMSA"); + roles.insert(StateKeyRole, "stateKey"); + roles.insert(AccountIdRole, "accountId"); return roles; } diff --git a/launcher/minecraft/auth/AccountList.h b/launcher/minecraft/auth/AccountList.h index dd98b093..6ebad73b 100644 --- a/launcher/minecraft/auth/AccountList.h +++ b/launcher/minecraft/auth/AccountList.h @@ -48,7 +48,23 @@ class AccountList : public QAbstractListModel NameRole = Qt::UserRole + 10, ProfileNameRole, TypeRole, - StatusRole + StatusRole, + + /* Added for the QML Accounts page (AccountsController). TypeRole + * above is a capitalized display string ("Msa"/"Offline"), not + * meant for comparisons, so IsMSARole gives an unambiguous flag + * instead. StateKeyRole is a stable, non-localized key parallel to + * StatusRole's translated display string, for QML that wants to + * branch on state (e.g. to pick a color) without matching + * translated text. AccountIdRole is the id to hand + * AccountFaceProvider (image://accountface/): the + * profile id, or the internalId for accounts that have none + * (offline, or MSA before a profile is fetched) -- the same + * fallback QmlShell::accountFace() uses for the default account. */ + IsDefaultRole, + IsMSARole, + StateKeyRole, + AccountIdRole }; enum VListColumns { diff --git a/launcher/minecraft/auth/AuthRequest.cpp b/launcher/minecraft/auth/AuthRequest.cpp index 3d2d9e3a..09c5963e 100644 --- a/launcher/minecraft/auth/AuthRequest.cpp +++ b/launcher/minecraft/auth/AuthRequest.cpp @@ -24,69 +24,33 @@ #include #include -#include "Application.h" #include "AuthRequest.h" -#include "plugin/PluginHooks.h" -#include "plugin/PluginManager.h" +#include "core/AuthRequestDecorator.h" +#include "core/LauncherContext.h" #include "katabasis/Globals.h" namespace { /* - * dispatchAuthRequestHook — run MMCO_HOOK_AUTH_REQUEST over the - * in-flight request and apply any redirect/header mutations the - * plugins request. + * dispatchAuthRequestHook — hand the in-flight request to whatever the + * plugin host hooked into outgoing authentication requests, if anything. * - * Returns true if the hook chain *cancelled* the request — the - * caller must abort and emit a network error in that case. + * Returns true if the request was *cancelled* — the caller must abort and + * emit a network error in that case. Header and redirect mutations are + * applied to `request` in place. + * + * The hook body itself lives in the plugin layer: running it means + * touching PluginManager, which builds plugin-supplied user interface and + * so drags QtWidgets in behind it. */ bool dispatchAuthRequestHook(QNetworkRequest& request, const QByteArray& body, const char* method) { - auto* pm = APPLICATION ? APPLICATION->pluginManager() : nullptr; - if (!pm) + auto* decorator = LAUNCHER ? LAUNCHER->authRequestDecorator() : nullptr; + if (!decorator) return false; - /* The add_header callback closes over the request reference and - * appends raw headers. We keep it as a thread-local C function - * pointer with a sidecar state struct so the closure can survive - * the C ABI boundary. */ - struct HeaderCtx { - QNetworkRequest* req; - }; - HeaderCtx hctx{&request}; - - auto add_header_fn = [](void* handle, const char* key, - const char* value) -> int { - if (!handle || !key || !value) - return -1; - auto* h = static_cast(handle); - h->req->setRawHeader(QByteArray(key), QByteArray(value)); - return 0; - }; - - const QByteArray urlUtf8 = request.url().toString().toUtf8(); - - MMCOAuthRequestEvent ev{}; - ev.url = urlUtf8.constData(); - ev.method = method; - ev.body = body.isEmpty() ? nullptr : body.constData(); - ev.body_size = body.size(); - ev.redirect_url = nullptr; - ev.request_handle = &hctx; - ev.add_header = add_header_fn; - - const bool cancelled = pm->dispatchHook(MMCO_HOOK_AUTH_REQUEST, &ev); - if (cancelled) - return true; - - if (ev.redirect_url && *ev.redirect_url) { - const QUrl rewritten = - QUrl::fromUserInput(QString::fromUtf8(ev.redirect_url)); - if (rewritten.isValid()) - request.setUrl(rewritten); - } - return false; + return decorator->dispatchAuthRequest(request, body, method); } } // namespace @@ -109,7 +73,7 @@ void AuthRequest::get(const QNetworkRequest& req, int timeout /* = 60*1000*/) return; } - reply_ = APPLICATION->network()->get(request_); + reply_ = LAUNCHER->network()->get(request_); status_ = Requesting; timedReplies_.add(new Katabasis::Reply(reply_, timeout)); connect(reply_, &QNetworkReply::errorOccurred, this, @@ -138,7 +102,7 @@ void AuthRequest::post(const QNetworkRequest& req, const QByteArray& data, } status_ = Requesting; - reply_ = APPLICATION->network()->post(request_, data_); + reply_ = LAUNCHER->network()->post(request_, data_); timedReplies_.add(new Katabasis::Reply(reply_, timeout)); connect(reply_, &QNetworkReply::errorOccurred, this, &AuthRequest::onRequestError); diff --git a/launcher/minecraft/auth/flows/AuthFlow.cpp b/launcher/minecraft/auth/flows/AuthFlow.cpp index aa493cf5..7ced191e 100644 --- a/launcher/minecraft/auth/flows/AuthFlow.cpp +++ b/launcher/minecraft/auth/flows/AuthFlow.cpp @@ -26,8 +26,6 @@ #include "AuthFlow.h" #include "katabasis/Globals.h" -#include - AuthFlow::AuthFlow(AccountData* data, QObject* parent) : AccountTask(data, parent) { diff --git a/launcher/minecraft/auth/steps/MSAStep.cpp b/launcher/minecraft/auth/steps/MSAStep.cpp index 4eb4640f..ba84342b 100644 --- a/launcher/minecraft/auth/steps/MSAStep.cpp +++ b/launcher/minecraft/auth/steps/MSAStep.cpp @@ -25,7 +25,7 @@ #include "minecraft/auth/AuthRequest.h" #include "minecraft/auth/Parsers.h" -#include "Application.h" +#include "core/LauncherContext.h" MSAStep::MSAStep(AccountData* data, Action action) : AuthStep(data), m_action(action) @@ -35,7 +35,7 @@ MSAStep::MSAStep(AccountData* data, Action action) tr("Login successful! You can close this page and return to MeshMC.")); m_oauth2 = new QOAuth2AuthorizationCodeFlow(this); - m_oauth2->setClientIdentifier(APPLICATION->msaClientId()); + m_oauth2->setClientIdentifier(LAUNCHER->msaClientId()); m_oauth2->setAuthorizationUrl(QUrl( "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize")); #if QT_VERSION >= QT_VERSION_CHECK(6, 9, 0) @@ -45,7 +45,7 @@ MSAStep::MSAStep(AccountData* data, Action action) #endif m_oauth2->setScope("XboxLive.signin offline_access"); m_oauth2->setReplyHandler(m_replyHandler); - m_oauth2->setNetworkAccessManager(APPLICATION->network().get()); + m_oauth2->setNetworkAccessManager(LAUNCHER->network().get()); connect(m_oauth2, &QOAuth2AuthorizationCodeFlow::granted, this, &MSAStep::onGranted); diff --git a/launcher/minecraft/launch/ClaimAccount.cpp b/launcher/minecraft/launch/ClaimAccount.cpp index 47945ed9..338efa08 100644 --- a/launcher/minecraft/launch/ClaimAccount.cpp +++ b/launcher/minecraft/launch/ClaimAccount.cpp @@ -20,7 +20,7 @@ #include "ClaimAccount.h" #include -#include "Application.h" +#include "core/LauncherContext.h" #include "minecraft/auth/AccountList.h" ClaimAccount::ClaimAccount(LaunchTask* parent, AuthSessionPtr session) @@ -28,7 +28,7 @@ ClaimAccount::ClaimAccount(LaunchTask* parent, AuthSessionPtr session) { if (session->status == AuthSession::Status::PlayableOnline && !session->demo) { - auto accounts = APPLICATION->accounts(); + auto accounts = LAUNCHER->accounts(); m_account = accounts->getAccountByProfileName(session->player_name); } } diff --git a/launcher/minecraft/launch/MeshMCPartLaunch.cpp b/launcher/minecraft/launch/MeshMCPartLaunch.cpp index b73e44d4..fea4d75c 100644 --- a/launcher/minecraft/launch/MeshMCPartLaunch.cpp +++ b/launcher/minecraft/launch/MeshMCPartLaunch.cpp @@ -27,7 +27,8 @@ #include "minecraft/MinecraftInstance.h" #include "FileSystem.h" #include "Commandline.h" -#include "Application.h" +#include "Logging.h" +#include "core/LauncherContext.h" MeshMCPartLaunch::MeshMCPartLaunch(LaunchTask* parent) : LaunchStep(parent) { @@ -88,7 +89,7 @@ void MeshMCPartLaunch::executeTask() auto classPath = minecraftInstance->getClassPath(); classPath.prepend( - FS::PathCombine(APPLICATION->getJarsPath(), "NewLaunch.jar")); + FS::PathCombine(LAUNCHER->getJarsPath(), "NewLaunch.jar")); auto natPath = minecraftInstance->getNativePath(); #ifdef Q_OS_WIN diff --git a/launcher/minecraft/launch/VerifyJavaInstall.cpp b/launcher/minecraft/launch/VerifyJavaInstall.cpp index fefa4985..d02e98a2 100644 --- a/launcher/minecraft/launch/VerifyJavaInstall.cpp +++ b/launcher/minecraft/launch/VerifyJavaInstall.cpp @@ -33,7 +33,7 @@ #include #include -#include "Application.h" +#include "core/LauncherContext.h" #include "FileSystem.h" #include "Json.h" #include "java/JavaUtils.h" @@ -56,7 +56,7 @@ namespace std::optional probeJavaVersion(const QString& javaPath) { const auto checkerJar = - FS::PathCombine(APPLICATION->getJarsPath(), "JavaCheck.jar"); + FS::PathCombine(LAUNCHER->getJarsPath(), "JavaCheck.jar"); if (!QFileInfo::exists(checkerJar)) { return std::nullopt; } @@ -221,7 +221,7 @@ void VerifyJavaInstall::fetchVersionList(int requiredMajor) QString uid = m_preferredVendor; QString url = QString("%1%2/index.json").arg(BuildConfig.META_URL, uid); - m_fetchJob = new NetJob(tr("Fetch Java versions"), APPLICATION->network()); + m_fetchJob = new NetJob(tr("Fetch Java versions"), LAUNCHER->network()); auto dl = Net::Download::makeByteArray(QUrl(url), &m_fetchData); m_fetchJob->addNetAction(dl); @@ -287,7 +287,7 @@ void VerifyJavaInstall::fetchRuntimes(const QString& versionId, QString("%1%2/%3.json").arg(BuildConfig.META_URL, uid, versionId); m_fetchJob = - new NetJob(tr("Fetch Java runtime details"), APPLICATION->network()); + new NetJob(tr("Fetch Java runtime details"), LAUNCHER->network()); auto dl = Net::Download::makeByteArray(QUrl(url), &m_fetchData); m_fetchJob->addNetAction(dl); diff --git a/launcher/minecraft/services/CapeChange.cpp b/launcher/minecraft/services/CapeChange.cpp index 65019047..86ed20db 100644 --- a/launcher/minecraft/services/CapeChange.cpp +++ b/launcher/minecraft/services/CapeChange.cpp @@ -22,7 +22,7 @@ #include #include -#include "Application.h" +#include "core/LauncherContext.h" CapeChange::CapeChange(QObject* parent, QString token, QString cape) : Task(parent), m_capeId(cape), m_token(token) @@ -37,7 +37,7 @@ void CapeChange::setCape(QString& cape) request.setRawHeader("Authorization", QString("Bearer %1").arg(m_token).toLocal8Bit()); QNetworkReply* rep = - APPLICATION->network()->put(request, requestString.toUtf8()); + LAUNCHER->network()->put(request, requestString.toUtf8()); setStatus(tr("Equipping cape")); @@ -55,7 +55,7 @@ void CapeChange::clearCape() auto requestString = QString("{\"capeId\":\"%1\"}").arg(m_capeId); request.setRawHeader("Authorization", QString("Bearer %1").arg(m_token).toLocal8Bit()); - QNetworkReply* rep = APPLICATION->network()->deleteResource(request); + QNetworkReply* rep = LAUNCHER->network()->deleteResource(request); setStatus(tr("Removing cape")); diff --git a/launcher/minecraft/services/SkinDelete.cpp b/launcher/minecraft/services/SkinDelete.cpp index 7c5a4c56..26da39af 100644 --- a/launcher/minecraft/services/SkinDelete.cpp +++ b/launcher/minecraft/services/SkinDelete.cpp @@ -22,7 +22,7 @@ #include #include -#include "Application.h" +#include "core/LauncherContext.h" SkinDelete::SkinDelete(QObject* parent, QString token) : Task(parent), m_token(token) @@ -35,7 +35,7 @@ void SkinDelete::executeTask() "https://api.minecraftservices.com/minecraft/profile/skins/active")); request.setRawHeader("Authorization", QString("Bearer %1").arg(m_token).toLocal8Bit()); - QNetworkReply* rep = APPLICATION->network()->deleteResource(request); + QNetworkReply* rep = LAUNCHER->network()->deleteResource(request); m_reply = shared_qobject_ptr(rep); setStatus(tr("Deleting skin")); diff --git a/launcher/minecraft/services/SkinUpload.cpp b/launcher/minecraft/services/SkinUpload.cpp index af52e0e7..d89aa6dd 100644 --- a/launcher/minecraft/services/SkinUpload.cpp +++ b/launcher/minecraft/services/SkinUpload.cpp @@ -22,7 +22,7 @@ #include #include -#include "Application.h" +#include "core/LauncherContext.h" QByteArray getVariant(SkinUpload::Model model) { @@ -65,7 +65,7 @@ void SkinUpload::executeTask() multiPart->append(skin); multiPart->append(model); - QNetworkReply* rep = APPLICATION->network()->post(request, multiPart); + QNetworkReply* rep = LAUNCHER->network()->post(request, multiPart); m_reply = shared_qobject_ptr(rep); setStatus(tr("Uploading skin")); diff --git a/launcher/minecraft/skins/ProfileSkinImport.cpp b/launcher/minecraft/skins/ProfileSkinImport.cpp index 755bedc6..41f6776d 100644 --- a/launcher/minecraft/skins/ProfileSkinImport.cpp +++ b/launcher/minecraft/skins/ProfileSkinImport.cpp @@ -25,7 +25,7 @@ #include #include -#include "Application.h" +#include "core/LauncherContext.h" #include "minecraft/auth/AccountData.h" #include "minecraft/auth/Parsers.h" #include "net/Download.h" @@ -65,7 +65,7 @@ void ProfileSkinImport::lookUpUuid() setStatus(tr("Looking up %1").arg(m_username)); m_response.clear(); - m_job = new NetJob(tr("Look up user"), APPLICATION->network()); + m_job = new NetJob(tr("Look up user"), LAUNCHER->network()); /* Percent-encoded: Mojang names are restricted to word characters today, * but this string comes straight out of a text field. */ const QString endpoint = @@ -110,7 +110,7 @@ void ProfileSkinImport::fetchProfile() setStatus(tr("Fetching the profile of %1").arg(m_username)); m_response.clear(); - m_job = new NetJob(tr("Download user profile"), APPLICATION->network()); + m_job = new NetJob(tr("Download user profile"), LAUNCHER->network()); const QString endpoint = QString::fromLatin1(kSessionProfileEndpoint) + m_uuid; m_job->addNetAction( @@ -151,7 +151,7 @@ void ProfileSkinImport::downloadTexture() { setStatus(tr("Downloading the skin of %1").arg(m_username)); - m_job = new NetJob(tr("Download user skin"), APPLICATION->network()); + m_job = new NetJob(tr("Download user skin"), LAUNCHER->network()); m_job->addNetAction( Net::Download::makeFile(QUrl(m_textureUrl), m_targetPath)); diff --git a/launcher/minecraft/update/AssetUpdateTask.cpp b/launcher/minecraft/update/AssetUpdateTask.cpp index 7ee01873..4fccce2d 100644 --- a/launcher/minecraft/update/AssetUpdateTask.cpp +++ b/launcher/minecraft/update/AssetUpdateTask.cpp @@ -24,7 +24,7 @@ #include "net/ChecksumValidator.h" #include "minecraft/AssetsUtils.h" -#include "Application.h" +#include "core/LauncherContext.h" AssetUpdateTask::AssetUpdateTask(MinecraftInstance* inst) { @@ -42,9 +42,9 @@ void AssetUpdateTask::executeTask() QUrl indexUrl = assets->url; QString localPath = assets->id + ".json"; auto job = new NetJob(tr("Asset index for %1").arg(m_inst->name()), - APPLICATION->network()); + LAUNCHER->network()); - auto metacache = APPLICATION->metacache(); + auto metacache = LAUNCHER->metacache(); auto entry = metacache->resolveEntry("asset_indexes", localPath); entry->setStale(true); auto hexSha1 = assets->sha1.toLatin1(); @@ -87,7 +87,7 @@ void AssetUpdateTask::assetIndexFinished() // FIXME: this looks like a job for a generic validator based on json // schema? if (!AssetsUtils::loadAssetsIndexJson(assets->id, asset_fname, index)) { - auto metacache = APPLICATION->metacache(); + auto metacache = LAUNCHER->metacache(); auto entry = metacache->resolveEntry("asset_indexes", assets->id + ".json"); metacache->evictEntry(entry); diff --git a/launcher/minecraft/update/FMLLibrariesTask.cpp b/launcher/minecraft/update/FMLLibrariesTask.cpp index 144a4546..a930cbe8 100644 --- a/launcher/minecraft/update/FMLLibrariesTask.cpp +++ b/launcher/minecraft/update/FMLLibrariesTask.cpp @@ -25,7 +25,7 @@ #include "minecraft/PackProfile.h" #include "BuildConfig.h" -#include "Application.h" +#include "core/LauncherContext.h" FMLLibrariesTask::FMLLibrariesTask(MinecraftInstance* inst) { @@ -75,8 +75,8 @@ void FMLLibrariesTask::executeTask() // download missing libs to our place setStatus(tr("Downloading FML libraries...")); - auto dljob = new NetJob("FML libraries", APPLICATION->network()); - auto metacache = APPLICATION->metacache(); + auto dljob = new NetJob("FML libraries", LAUNCHER->network()); + auto metacache = LAUNCHER->metacache(); for (auto& lib : fmlLibsToProcess) { auto entry = metacache->resolveEntry("fmllibs", lib.filename); QString urlString = BuildConfig.FMLLIBS_BASE_URL + lib.filename; @@ -104,7 +104,7 @@ void FMLLibrariesTask::fmllibsFinished() if (!fmlLibsToProcess.isEmpty()) { setStatus(tr("Copying FML libraries into the instance...")); MinecraftInstance* inst = (MinecraftInstance*)m_inst; - auto metacache = APPLICATION->metacache(); + auto metacache = LAUNCHER->metacache(); int index = 0; for (auto& lib : fmlLibsToProcess) { progress(index, fmlLibsToProcess.size()); diff --git a/launcher/minecraft/update/LibrariesTask.cpp b/launcher/minecraft/update/LibrariesTask.cpp index d8ec2852..b1de75d6 100644 --- a/launcher/minecraft/update/LibrariesTask.cpp +++ b/launcher/minecraft/update/LibrariesTask.cpp @@ -22,7 +22,7 @@ #include "minecraft/MinecraftInstance.h" #include "minecraft/PackProfile.h" -#include "Application.h" +#include "core/LauncherContext.h" LibrariesTask::LibrariesTask(MinecraftInstance* inst) { @@ -40,10 +40,10 @@ void LibrariesTask::executeTask() auto profile = components->getProfile(); auto job = new NetJob(tr("Libraries for instance %1").arg(inst->name()), - APPLICATION->network()); + LAUNCHER->network()); downloadJob.reset(job); - auto metacache = APPLICATION->metacache(); + auto metacache = LAUNCHER->metacache(); auto processArtifactPool = [&](const QList& pool, QStringList& errors, diff --git a/launcher/models/AccountsController.cpp b/launcher/models/AccountsController.cpp new file mode 100644 index 00000000..072f5d90 --- /dev/null +++ b/launcher/models/AccountsController.cpp @@ -0,0 +1,414 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "AccountsController.h" + +#include +#include +#include + +#include "core/LauncherContext.h" +#include "minecraft/auth/AccountTask.h" +#include "minecraft/services/CapeChange.h" +#include "minecraft/services/SkinDelete.h" +#include "minecraft/services/SkinUpload.h" +#include "minecraft/skins/SkinEntry.h" +#include "tasks/SequentialTask.h" +#include "tasks/Task.h" +#include "tasks/TaskWatcher.h" + +namespace +{ + /* A QML FileDialog hands out a "file://" url; validateSkinFile()/ + * changeSkin() also accept a plain path, so either can be passed + * straight through from QML without the caller converting it first. + * Same conversion InstanceDetails::install() uses for the same reason. */ + QString toLocalPath(const QString& fileUrlOrPath) + { + const QUrl url(fileUrlOrPath); + return url.isLocalFile() ? url.toLocalFile() : fileUrlOrPath; + } +} // namespace + +AccountsController::AccountsController(shared_qobject_ptr accounts, + QObject* parent) + : QObject(parent), m_accounts(std::move(accounts)) +{ + /* Either one moving the default account or the list itself (a + * refreshed account's profileName can change what defaultName() + * reports) is reason enough to re-read both properties - same + * reasoning as QmlShell's own accountChanged() bump. */ + connect(m_accounts.get(), &AccountList::listChanged, this, + &AccountsController::defaultChanged); + connect(m_accounts.get(), &AccountList::defaultAccountChanged, this, + &AccountsController::defaultChanged); +} + +QObject* AccountsController::accounts() const +{ + return m_accounts.get(); +} + +bool AccountsController::hasDefault() const +{ + return m_accounts->defaultAccount().get() != nullptr; +} + +QString AccountsController::defaultName() const +{ + auto account = m_accounts->defaultAccount(); + return account ? account->profileName() : QString(); +} + +void AccountsController::setDefault(int row) +{ + if (row < 0 || row >= m_accounts->count()) { + return; + } + m_accounts->setDefaultAccount(m_accounts->at(row)); +} + +void AccountsController::clearDefault() +{ + m_accounts->setDefaultAccount(nullptr); +} + +void AccountsController::remove(int row) +{ + if (row < 0 || row >= m_accounts->count()) { + return; + } + m_accounts->removeAccount(m_accounts->index(row)); +} + +void AccountsController::refresh(int row) +{ + if (row < 0 || row >= m_accounts->count()) { + return; + } + auto account = m_accounts->at(row); + if (!account) { + return; + } + m_accounts->requestRefresh(account->internalId()); +} + +bool AccountsController::addOffline(const QString& username) +{ + // Same rule as AccountListPage::on_actionAddOffline_triggered(): an + // offline account is only useful once there is a Microsoft account to + // actually play under, since offline mode exists for testing/LAN play + // alongside a real account rather than as a Minecraft account + // replacement. + bool hasMSA = false; + for (int i = 0; i < m_accounts->count(); i++) { + if (m_accounts->at(i)->isMSA()) { + hasMSA = true; + break; + } + } + if (!hasMSA) { + return false; + } + + const QString trimmed = username.trimmed(); + if (trimmed.isEmpty()) { + return false; + } + + // AccountList::addAccount() already guards against this internally, + // but checking here lets the caller distinguish "rejected" from + // "added" instead of silently doing nothing. + if (m_accounts->findOfflineAccountByUsername(trimmed) != -1) { + return false; + } + + m_accounts->addAccount(MinecraftAccount::createOffline(trimmed)); + return true; +} + +QObject* AccountsController::loginMicrosoft() +{ + // Parented to this, not left parentless: QML calls this directly on + // the accountsController property rather than through QmlShell, so + // there is no expose() call sitting between here and QML to pin an + // otherwise-parentless return value with CppOwnership. A real parent + // sidesteps the question entirely - see ModrinthModpackModel::install(), + // which parents its returned TaskWatcher to itself for the same reason. + return new MicrosoftLoginController(m_accounts, this); +} + +bool AccountsController::skinDemoRequested() const +{ + return qEnvironmentVariable("MESHMC_QML_ROUTE") + .contains(QStringLiteral("accounts-demo")); +} + +QVariantMap AccountsController::accountSkinInfo(int row) const +{ + QVariantMap info; + info[QStringLiteral("valid")] = false; + info[QStringLiteral("slim")] = false; + info[QStringLiteral("currentCapeId")] = QString(); + info[QStringLiteral("capes")] = QVariantList(); + + /* Row -1 is the qml-preview-tools demo sentinel (see + * skinDemoRequested()/AccountsPage.qml): the preview's account file + * only ever has offline accounts, so no real row's isMSA role would + * let the page reach this editor otherwise. Canned data, not a faked + * account file, and only handed back when actually asked for -- a + * plain out-of-range -1 (what every other row here already gets) + * still reads as invalid the rest of the time. */ + if (row == -1 && skinDemoRequested()) { + info[QStringLiteral("valid")] = true; + info[QStringLiteral("currentCapeId")] = QStringLiteral("demo-ember"); + QVariantMap ember; + ember[QStringLiteral("id")] = QStringLiteral("demo-ember"); + ember[QStringLiteral("alias")] = QStringLiteral("Ember"); + ember[QStringLiteral("url")] = QString(); + QVariantMap vault; + vault[QStringLiteral("id")] = QStringLiteral("demo-vault"); + vault[QStringLiteral("alias")] = QStringLiteral("Vault"); + vault[QStringLiteral("url")] = QString(); + info[QStringLiteral("capes")] = QVariantList{ ember, vault }; + return info; + } + + if (row < 0 || row >= m_accounts->count()) { + return info; + } + auto account = m_accounts->at(row); + if (!account || !account->isMSA() || !account->accountData()) { + return info; + } + + const MinecraftProfile& profile = account->accountData()->minecraftProfile; + info[QStringLiteral("valid")] = true; + info[QStringLiteral("slim")] = + profile.skin.variant == QLatin1String("SLIM"); + info[QStringLiteral("currentCapeId")] = profile.currentCape; + + QVariantList capes; + for (const Cape& cape : profile.capes) { + QVariantMap c; + c[QStringLiteral("id")] = cape.id; + c[QStringLiteral("alias")] = cape.alias; + c[QStringLiteral("url")] = cape.url; + capes.append(c); + } + info[QStringLiteral("capes")] = capes; + return info; +} + +QString AccountsController::validateSkinFile(const QString& path) const +{ + SkinEntry entry(toLocalPath(path)); + if (!entry.isUsable()) { + return tr("Skin images must be 64x64 or 64x32 pixel PNG files."); + } + return QString(); +} + +QObject* AccountsController::changeSkin(int row, const QString& path, bool slim) +{ + if (row < 0 || row >= m_accounts->count()) { + return nullptr; + } + auto account = m_accounts->at(row); + if (!account || !account->isMSA()) { + return nullptr; + } + + QFile file(toLocalPath(path)); + if (!file.open(QIODevice::ReadOnly)) { + return nullptr; + } + const QByteArray texture = file.readAll(); + file.close(); + + /* Same SkinUpload + refresh sequence SkinManageDialog::accept() runs, + * minus the cape change: this uploads a picked file directly rather + * than editing a local skin-library entry that already carries a cape + * choice, so there is nothing here to carry over. */ + auto* job = new SequentialTask(nullptr, tr("Change skin")); + job->addTask(Task::Ptr(new SkinUpload( + nullptr, account->accessToken(), texture, + slim ? SkinUpload::ALEX : SkinUpload::STEVE))); + job->addTask(account->refresh()); + + auto* watcher = new TaskWatcher(Task::Ptr(job), this); + watcher->setTitle(tr("Change skin")); + job->start(); + return watcher; +} + +QObject* AccountsController::resetSkin(int row) +{ + if (row < 0 || row >= m_accounts->count()) { + return nullptr; + } + auto account = m_accounts->at(row); + if (!account || !account->isMSA()) { + return nullptr; + } + + auto* job = new SequentialTask(nullptr, tr("Reset skin")); + job->addTask(Task::Ptr(new SkinDelete(nullptr, account->accessToken()))); + job->addTask(account->refresh()); + + auto* watcher = new TaskWatcher(Task::Ptr(job), this); + watcher->setTitle(tr("Reset skin")); + job->start(); + return watcher; +} + +QObject* AccountsController::changeCape(int row, const QString& capeId) +{ + if (row < 0 || row >= m_accounts->count()) { + return nullptr; + } + auto account = m_accounts->at(row); + if (!account || !account->isMSA()) { + return nullptr; + } + + auto* job = new SequentialTask(nullptr, tr("Change cape")); + job->addTask( + Task::Ptr(new CapeChange(nullptr, account->accessToken(), capeId))); + job->addTask(account->refresh()); + + auto* watcher = new TaskWatcher(Task::Ptr(job), this); + watcher->setTitle(tr("Change cape")); + job->start(); + return watcher; +} + +MicrosoftLoginController::MicrosoftLoginController( + shared_qobject_ptr accounts, QObject* parent) + : QObject(parent), m_accountList(std::move(accounts)) +{ + if (LAUNCHER->msaClientId().isEmpty()) { + // Mirrors AccountListPage's ui->actionAddMicrosoft->setVisible() + // guard, which hid the button entirely rather than let it fail; + // this controller has no visibility to hide, so it starts + // already failed instead. (The widget page's separate osx64 + // warning dialog is not reproduced here: BUILD_PLATFORM is never + // actually set to that value by this fork's build, so it never + // fires today.) + m_error = tr( + "Microsoft login is not available: no client id is configured " + "for this build."); + m_failed = true; + return; + } + + m_running = true; + m_status = tr("Opening your browser for Microsoft login..."); + + m_account = MinecraftAccount::createBlankMSA(); + m_task = m_account->loginMSA(); + + connect(m_task.get(), &Task::status, this, + &MicrosoftLoginController::onStatus); + connect(m_task.get(), &Task::succeeded, this, + &MicrosoftLoginController::onSucceeded); + connect(m_task.get(), &Task::failed, this, + &MicrosoftLoginController::onFailed); + connect(m_task.get(), &AccountTask::authorizeWithBrowser, this, + &MicrosoftLoginController::onAuthorizeWithBrowser); + + m_task->start(); +} + +MicrosoftLoginController::~MicrosoftLoginController() {} + +void MicrosoftLoginController::cancel() +{ + if (!m_running) { + return; + } + // No supported way to actually abort an in-flight OAuth2 request (see + // the header comment) - dropping our references is what the widget + // dialog effectively did when closed mid-flow, since nothing else + // keeps m_account/m_task alive once this does not. + if (m_task) { + m_task->disconnect(this); + } + m_task.reset(); + m_account.reset(); + + m_running = false; + emit runningChanged(); +} + +void MicrosoftLoginController::openBrowser() +{ + if (m_browserUrl.isEmpty()) { + return; + } + QDesktopServices::openUrl(m_browserUrl); +} + +void MicrosoftLoginController::onStatus(const QString& status) +{ + if (m_status == status) { + return; + } + m_status = status; + emit statusChanged(); +} + +void MicrosoftLoginController::onAuthorizeWithBrowser(const QUrl& url) +{ + m_browserUrl = url; + emit browserUrlChanged(); +} + +void MicrosoftLoginController::onSucceeded() +{ + // Exactly what AccountListPage::on_actionAddMicrosoft_triggered() did + // with the widget dialog's result: file the new account in, and make + // it the default if it's the first one in the list. + m_accountList->addAccount(m_account); + if (m_accountList->count() == 1) { + m_accountList->setDefaultAccount(m_account); + } + + m_running = false; + emit runningChanged(); + m_succeeded = true; + emit succeededChanged(); +} + +void MicrosoftLoginController::onFailed(const QString& reason) +{ + setFailed(reason); +} + +void MicrosoftLoginController::setFailed(const QString& reason) +{ + if (m_running) { + m_running = false; + emit runningChanged(); + } + m_error = reason; + emit errorChanged(); + m_failed = true; + emit failedChanged(); +} diff --git a/launcher/models/AccountsController.h b/launcher/models/AccountsController.h new file mode 100644 index 00000000..eb13dffd --- /dev/null +++ b/launcher/models/AccountsController.h @@ -0,0 +1,269 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include + +#include "QObjectPtr.h" +#include "minecraft/auth/AccountList.h" +#include "minecraft/auth/MinecraftAccount.h" + +class AccountTask; + +/* + * QML-facing replacement for the widget AccountListPage: everything that + * page's toolbar actions did (add Microsoft/offline, remove, refresh, set/ + * clear default), plus the Microsoft sign-in flow itself, as one object a + * QML Accounts page can bind against and call into. + * + * The account list itself is not wrapped -- `accounts` hands QML the + * AccountList model directly, the same object LAUNCHER->accounts() returns, + * so a QML ListView binds to it exactly like any other list model here (see + * AccountList::roleNames()). Everything else here operates on it by row, + * the same way the widget page operated on the view's current selection. + * + * Core, like the rest of models/: QtCore only, no QtWidgets, no ui/. + */ +class AccountsController : public QObject +{ + Q_OBJECT + + Q_PROPERTY(QObject* accounts READ accounts CONSTANT) + Q_PROPERTY(bool hasDefault READ hasDefault NOTIFY defaultChanged) + /// profileName() of the default account, or empty if there is none. + Q_PROPERTY(QString defaultName READ defaultName NOTIFY defaultChanged) + /// See skinDemoRequested() below. + Q_PROPERTY(bool skinDemoRequested READ skinDemoRequested CONSTANT) + + public: + explicit AccountsController(shared_qobject_ptr accounts, + QObject* parent = nullptr); + + QObject* accounts() const; + bool hasDefault() const; + QString defaultName() const; + + /// Makes row @p row the default account. No-op if @p row is out of range. + Q_INVOKABLE void setDefault(int row); + /// Clears the default account, if any. + Q_INVOKABLE void clearDefault(); + /// Removes row @p row. No-op if @p row is out of range. + Q_INVOKABLE void remove(int row); + /// Requests a token refresh for row @p row, ahead of the background + /// refresh queue -- same as AccountList::requestRefresh(). No-op if + /// @p row is out of range. + Q_INVOKABLE void refresh(int row); + + /*! + * Adds an offline account named @p username, applying the same rules + * the old offline dialog enforced: + * - at least one Microsoft account must already be in the list; + * - the trimmed username must not be empty; + * - it must not collide (case-insensitively) with an existing offline + * account's username. + * Returns false, without adding anything, if any rule is violated. + */ + Q_INVOKABLE bool addOffline(const QString& username); + + /*! + * Starts an interactive Microsoft sign-in and returns a controller for + * it, owned by C++ (QQmlEngine::CppOwnership -- see QmlShell::expose()): + * the login must outlive the page that started it if the user navigates + * away mid-flow. On success the new account is added to the list, and + * made the default if it was the first account -- exactly what the + * widget dialog's caller did. + */ + Q_INVOKABLE QObject* loginMicrosoft(); + + /*! + * Skin and cape state for row @p row's Microsoft profile, for the QML + * skin/cape editor (see AccountsPage.qml): { "valid": bool, "slim": + * bool, "currentCapeId": string, "capes": [{ "id", "alias", "url" }] }. + * "valid" is false -- and every other field is at its empty default -- + * for a row out of range, an offline account, or an MSA account with no + * profile data yet; the caller is expected to check it before reading + * the rest, the same way it already checks the row's own isMSA role + * before offering skin controls at all. + */ + Q_INVOKABLE QVariantMap accountSkinInfo(int row) const; + + /*! + * Whether MESHMC_QML_ROUTE asks for the skin/cape editor to be + * demoed (contains "accounts-demo") -- the qml-preview-tools snapshot + * account file only ever has offline accounts, so no real row's isMSA + * role would ever let AccountsPage.qml reach that editor otherwise. + * When this is true, accountSkinInfo(-1) hands back canned demo data + * instead of "invalid", for AccountsPage.qml to open the editor with. + * False, and accountSkinInfo(-1) stays "invalid", the rest of the time. + */ + bool skinDemoRequested() const; + + /*! + * Whether @p path is a PNG MinecraftServices will accept as a skin (64 + * wide, 64 or 32 tall -- see SkinEntry::isUsable()). Returns an empty + * string if it is fine to upload, or a user-facing reason it is not. + * @p path may be a local path or a "file://" url, same as QML's + * FileDialog hands out. + */ + Q_INVOKABLE QString validateSkinFile(const QString& path) const; + + /*! + * Uploads @p path (already checked with validateSkinFile()) as row @p + * row's skin, @p slim choosing the arm width, and refreshes the account + * afterwards -- the same SkinUpload + refresh sequence SkinManageDialog:: + * accept() runs. Returns a TaskWatcher (see loginMicrosoft() above for + * the ownership reasoning); nullptr without starting anything if @p row + * is out of range, is not a Microsoft account, or @p path could not be + * opened. + */ + Q_INVOKABLE QObject* changeSkin(int row, const QString& path, bool slim); + + /*! + * Deletes row @p row's custom skin (back to the Mojang default) and + * refreshes afterwards. Returns a TaskWatcher; nullptr as changeSkin() + * above if @p row is out of range or is not a Microsoft account. + */ + Q_INVOKABLE QObject* resetSkin(int row); + + /*! + * Equips cape @p capeId on row @p row's account, or takes the current + * cape off for an empty @p capeId, and refreshes afterwards. Does not + * check that the account actually owns @p capeId first -- one it does + * not own fails server-side, same as it would through the classic + * dialog's combo box. Returns a TaskWatcher; nullptr as changeSkin() + * above if @p row is out of range or is not a Microsoft account. + */ + Q_INVOKABLE QObject* changeCape(int row, const QString& capeId); + + signals: + /// hasDefault()/defaultName() moved. + void defaultChanged(); + + private: + shared_qobject_ptr m_accounts; +}; + +/* + * One Microsoft sign-in attempt, as started by AccountsController:: + * loginMicrosoft(). + * + * MeshMC's Microsoft login (MSAStep, launcher/minecraft/auth/steps/ + * MSAStep.cpp) is an OAuth2 authorization-code flow, not a device code + * flow: QOAuth2AuthorizationCodeFlow opens the system browser to a + * microsoftonline.com login page and a local HTTP server (bound to + * localhost, chosen by Qt) catches the redirect. There is no user code to + * display -- only the URL the browser was (or should have been) sent to, + * which the browser is opened for automatically as soon as it is known; + * browserUrl and openBrowser() exist for a user who closed that tab and + * wants it back. + * + * running/succeeded/failed/error mirror TaskWatcher's properties (tasks/ + * TaskWatcher.h) but this is not a TaskWatcher: TaskWatcher only watches an + * already-meaningful task, whereas this also owns the blank account the + * flow fills in and, on success, files it into the AccountList itself -- + * concerns TaskWatcher deliberately knows nothing about. + */ +class MicrosoftLoginController : public QObject +{ + Q_OBJECT + + Q_PROPERTY(QString status READ status NOTIFY statusChanged) + /// The URL the browser was sent to, once known; empty until then. + Q_PROPERTY(QUrl browserUrl READ browserUrl NOTIFY browserUrlChanged) + Q_PROPERTY(bool running READ running NOTIFY runningChanged) + Q_PROPERTY(bool succeeded READ succeeded NOTIFY succeededChanged) + Q_PROPERTY(bool failed READ failed NOTIFY failedChanged) + /// The failure reason, if any. Empty while running or on success. + Q_PROPERTY(QString error READ error NOTIFY errorChanged) + + public: + explicit MicrosoftLoginController(shared_qobject_ptr accounts, + QObject* parent = nullptr); + ~MicrosoftLoginController() override; + + QString status() const + { + return m_status; + } + QUrl browserUrl() const + { + return m_browserUrl; + } + bool running() const + { + return m_running; + } + bool succeeded() const + { + return m_succeeded; + } + bool failed() const + { + return m_failed; + } + QString error() const + { + return m_error; + } + + /*! + * Gives up on this attempt. There is no server-side cancellation for + * an in-flight OAuth2 request (AccountTask/Task never override + * canAbort()/abort() to make one possible -- the widget dialog had the + * same limitation, and simply let a closed dialog's task run to + * completion unobserved); this instead drops this controller's only + * references to the pending account and task, matching what happened + * when that dialog was destroyed. No-op once already finished. + */ + Q_INVOKABLE void cancel(); + /// Re-opens the system browser at browserUrl(). No-op before it is known. + Q_INVOKABLE void openBrowser(); + + signals: + void statusChanged(); + void browserUrlChanged(); + void runningChanged(); + void succeededChanged(); + void failedChanged(); + void errorChanged(); + + private slots: + void onStatus(const QString& status); + void onAuthorizeWithBrowser(const QUrl& url); + void onSucceeded(); + void onFailed(const QString& reason); + + private: + void setFailed(const QString& reason); + + shared_qobject_ptr m_accountList; + MinecraftAccountPtr m_account; + shared_qobject_ptr m_task; + + QString m_status; + QUrl m_browserUrl; + bool m_running = false; + bool m_succeeded = false; + bool m_failed = false; + QString m_error; +}; diff --git a/launcher/models/AccountsController_test.cpp b/launcher/models/AccountsController_test.cpp new file mode 100644 index 00000000..492aaff5 --- /dev/null +++ b/launcher/models/AccountsController_test.cpp @@ -0,0 +1,365 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include +#include +#include +#include +#include + +#include "minecraft/auth/AccountList.h" +#include "minecraft/auth/MinecraftAccount.h" +#include "models/AccountsController.h" + +/* + * Covers what does not need a LauncherContext or a network round trip: + * AccountList's QML roles (including the ones this page added) and the + * parts of AccountsController that only touch AccountList/MinecraftAccount. + * + * MinecraftAccount::createBlankMSA() marks an account as MSA-typed without + * performing any login - exactly what's needed to satisfy addOffline()'s + * "at least one Microsoft account exists" rule in a test. Actually signing + * in (AccountsController::loginMicrosoft()) goes through MSAStep, which + * reaches LAUNCHER->network() - that needs a real LauncherContext and is + * deliberately not exercised here, per instructions not to perform network + * auth calls in tests. + */ +class AccountsControllerTest : public QObject +{ + Q_OBJECT + + private slots: + void test_roleNames_includeQmlRoles() + { + AccountList list; + auto roles = list.roleNames(); + QCOMPARE(roles.value(AccountList::NameRole), QByteArray("name")); + QCOMPARE(roles.value(AccountList::ProfileNameRole), + QByteArray("profileName")); + QCOMPARE(roles.value(AccountList::TypeRole), QByteArray("type")); + QCOMPARE(roles.value(AccountList::StatusRole), QByteArray("status")); + QCOMPARE(roles.value(AccountList::IsDefaultRole), + QByteArray("isDefault")); + QCOMPARE(roles.value(AccountList::IsMSARole), QByteArray("isMSA")); + QCOMPARE(roles.value(AccountList::StateKeyRole), + QByteArray("stateKey")); + QCOMPARE(roles.value(AccountList::AccountIdRole), + QByteArray("accountId")); + } + + void test_data_isMSA_and_accountId_distinguishAccountTypes() + { + AccountList list; + auto offline = MinecraftAccount::createOffline("Steve"); + auto msa = MinecraftAccount::createBlankMSA(); + list.addAccount(offline); + list.addAccount(msa); + + const QModelIndex offlineIdx = list.index(0); + const QModelIndex msaIdx = list.index(1); + + QVERIFY(!list.data(offlineIdx, AccountList::IsMSARole).toBool()); + QVERIFY(list.data(msaIdx, AccountList::IsMSARole).toBool()); + + // Neither has a Mojang profile id yet, so both fall back to + // internalId() - and it must actually distinguish them. + const QString offlineId = + list.data(offlineIdx, AccountList::AccountIdRole).toString(); + const QString msaId = + list.data(msaIdx, AccountList::AccountIdRole).toString(); + QCOMPARE(offlineId, offline->internalId()); + QCOMPARE(msaId, msa->internalId()); + QVERIFY(offlineId != msaId); + } + + void test_data_isDefault_and_stateKey_trackState() + { + AccountList list; + auto offline = MinecraftAccount::createOffline("Steve"); + list.addAccount(offline); + const QModelIndex idx = list.index(0); + + QVERIFY(!list.data(idx, AccountList::IsDefaultRole).toBool()); + // createOffline() leaves new offline accounts already Online. + QCOMPARE(list.data(idx, AccountList::StateKeyRole).toString(), + QStringLiteral("online")); + + list.setDefaultAccount(offline); + QVERIFY(list.data(idx, AccountList::IsDefaultRole).toBool()); + } + + void test_hasDefault_and_defaultName_followTheList() + { + auto list = shared_qobject_ptr(new AccountList()); + AccountsController controller(list); + QSignalSpy spy(&controller, &AccountsController::defaultChanged); + + QVERIFY(!controller.hasDefault()); + QVERIFY(controller.defaultName().isEmpty()); + + auto account = MinecraftAccount::createOffline("Alex"); + list->addAccount(account); + list->setDefaultAccount(account); + + QVERIFY(controller.hasDefault()); + QCOMPARE(controller.defaultName(), QStringLiteral("Alex")); + QVERIFY(spy.count() > 0); + } + + void test_setDefault_and_clearDefault_byRow() + { + auto list = shared_qobject_ptr(new AccountList()); + AccountsController controller(list); + + list->addAccount(MinecraftAccount::createOffline("Alex")); + list->addAccount(MinecraftAccount::createOffline("Bob")); + + controller.setDefault(1); + QCOMPARE(controller.defaultName(), QStringLiteral("Bob")); + + controller.clearDefault(); + QVERIFY(!controller.hasDefault()); + } + + void test_setDefault_outOfRange_isNoOp() + { + auto list = shared_qobject_ptr(new AccountList()); + AccountsController controller(list); + list->addAccount(MinecraftAccount::createOffline("Alex")); + + controller.setDefault(5); + controller.setDefault(-1); + + QVERIFY(!controller.hasDefault()); + } + + void test_remove_dropsTheRow() + { + auto list = shared_qobject_ptr(new AccountList()); + AccountsController controller(list); + list->addAccount(MinecraftAccount::createOffline("Alex")); + list->addAccount(MinecraftAccount::createOffline("Bob")); + QCOMPARE(list->count(), 2); + + controller.remove(0); + + QCOMPARE(list->count(), 1); + QCOMPARE(list->at(0)->profileName(), QStringLiteral("Bob")); + } + + void test_remove_outOfRange_isNoOp() + { + auto list = shared_qobject_ptr(new AccountList()); + AccountsController controller(list); + list->addAccount(MinecraftAccount::createOffline("Alex")); + + controller.remove(5); + controller.remove(-1); + + QCOMPARE(list->count(), 1); + } + + // refresh() on a valid row would start a real MSASilent/QOAuth2 network + // task via AccountList::requestRefresh() -> tryNext() -> LAUNCHER-> - not + // safe without a LauncherContext. Only the bounds check is exercised. + void test_refresh_outOfRange_isNoOp() + { + auto list = shared_qobject_ptr(new AccountList()); + AccountsController controller(list); + list->addAccount(MinecraftAccount::createOffline("Alex")); + + controller.refresh(5); + controller.refresh(-1); + + QCOMPARE(list->count(), 1); + } + + void test_addOffline_requiresAnMSAAccountFirst() + { + auto list = shared_qobject_ptr(new AccountList()); + AccountsController controller(list); + + QVERIFY(!controller.addOffline("Steve")); + QCOMPARE(list->count(), 0); + + list->addAccount(MinecraftAccount::createBlankMSA()); + QVERIFY(controller.addOffline("Steve")); + QCOMPARE(list->count(), 2); + } + + void test_addOffline_rejectsEmptyOrWhitespaceUsername() + { + auto list = shared_qobject_ptr(new AccountList()); + AccountsController controller(list); + list->addAccount(MinecraftAccount::createBlankMSA()); + + QVERIFY(!controller.addOffline("")); + QVERIFY(!controller.addOffline(" ")); + QCOMPARE(list->count(), 1); + } + + void test_addOffline_trimsUsername() + { + auto list = shared_qobject_ptr(new AccountList()); + AccountsController controller(list); + list->addAccount(MinecraftAccount::createBlankMSA()); + + QVERIFY(controller.addOffline(" Steve ")); + QCOMPARE(list->at(1)->profileName(), QStringLiteral("Steve")); + } + + void test_addOffline_rejectsCaseInsensitiveDuplicate() + { + auto list = shared_qobject_ptr(new AccountList()); + AccountsController controller(list); + list->addAccount(MinecraftAccount::createBlankMSA()); + QVERIFY(controller.addOffline("Steve")); + + QVERIFY(!controller.addOffline("steve")); + QCOMPARE(list->count(), 2); + } + + void test_accountSkinInfo_outOfRangeOrOffline_isInvalid() + { + auto list = shared_qobject_ptr(new AccountList()); + AccountsController controller(list); + list->addAccount(MinecraftAccount::createOffline("Steve")); + + QVERIFY(!controller.accountSkinInfo(5).value("valid").toBool()); + // Row 0 exists, but is offline, not a Microsoft account. + QVERIFY(!controller.accountSkinInfo(0).value("valid").toBool()); + // -1 without the demo route set is just another out-of-range row. + qunsetenv("MESHMC_QML_ROUTE"); + QVERIFY(!controller.skinDemoRequested()); + QVERIFY(!controller.accountSkinInfo(-1).value("valid").toBool()); + } + + // qml-preview-tools' snapshot account file only ever has offline + // accounts, so this is the only way the skin/cape editor can be + // rendered for review -- see AccountsController::skinDemoRequested()'s + // own comment. + void test_accountSkinInfo_demoRoute_fillsRowMinusOne() + { + auto list = shared_qobject_ptr(new AccountList()); + AccountsController controller(list); + + qputenv("MESHMC_QML_ROUTE", "page=accounts;accounts-demo=msa"); + QVERIFY(controller.skinDemoRequested()); + + const QVariantMap demo = controller.accountSkinInfo(-1); + QVERIFY(demo.value("valid").toBool()); + QCOMPARE(demo.value("capes").toList().size(), 2); + + qunsetenv("MESHMC_QML_ROUTE"); + } + + void test_accountSkinInfo_msaAccount_readsProfile() + { + auto list = shared_qobject_ptr(new AccountList()); + AccountsController controller(list); + auto msa = MinecraftAccount::createBlankMSA(); + msa->accountData()->minecraftProfile.skin.variant = "SLIM"; + msa->accountData()->minecraftProfile.currentCape = "cape-1"; + Cape cape; + cape.id = "cape-1"; + cape.alias = "Cool Cape"; + cape.url = "https://example.test/cape.png"; + msa->accountData()->minecraftProfile.capes.append(cape); + list->addAccount(msa); + + const QVariantMap info = controller.accountSkinInfo(0); + QVERIFY(info.value("valid").toBool()); + QVERIFY(info.value("slim").toBool()); + QCOMPARE(info.value("currentCapeId").toString(), + QStringLiteral("cape-1")); + + const QVariantList capes = info.value("capes").toList(); + QCOMPARE(capes.size(), 1); + const QVariantMap firstCape = capes.first().toMap(); + QCOMPARE(firstCape.value("id").toString(), QStringLiteral("cape-1")); + QCOMPARE(firstCape.value("alias").toString(), + QStringLiteral("Cool Cape")); + QCOMPARE(firstCape.value("url").toString(), + QStringLiteral("https://example.test/cape.png")); + } + + void test_validateSkinFile_rejectsWrongSizedImage() + { + auto list = shared_qobject_ptr(new AccountList()); + AccountsController controller(list); + + QTemporaryFile file(QDir::tempPath() + + QStringLiteral("/AccountsControllerTest_XXXXXX.png")); + QVERIFY(file.open()); + const QString path = file.fileName(); + file.close(); + + QImage badImage(16, 16, QImage::Format_ARGB32); + badImage.fill(Qt::transparent); + QVERIFY(badImage.save(path, "PNG")); + + QVERIFY(!controller.validateSkinFile(path).isEmpty()); + } + + void test_validateSkinFile_acceptsSkinSizedImage_andFileUrl() + { + auto list = shared_qobject_ptr(new AccountList()); + AccountsController controller(list); + + QTemporaryFile file(QDir::tempPath() + + QStringLiteral("/AccountsControllerTest_XXXXXX.png")); + QVERIFY(file.open()); + const QString path = file.fileName(); + file.close(); + + QImage goodImage(64, 32, QImage::Format_ARGB32); + goodImage.fill(Qt::transparent); + QVERIFY(goodImage.save(path, "PNG")); + + QVERIFY(controller.validateSkinFile(path).isEmpty()); + // A QML FileDialog hands out a "file://" url rather than a bare path. + QVERIFY(controller.validateSkinFile(QUrl::fromLocalFile(path).toString()) + .isEmpty()); + } + + // changeSkin()/resetSkin()/changeCape() all start a real network task + // (SkinUpload/SkinDelete/CapeChange -> LAUNCHER->network()) once past + // their guard clauses - not safe without a LauncherContext, and not + // performed here per instructions not to make network calls in tests. + // Only the row-out-of-range and non-Microsoft-account guards, which + // return before starting anything, are exercised. + void test_changeSkin_resetSkin_changeCape_outOfRangeOrNonMSA_areNoOp() + { + auto list = shared_qobject_ptr(new AccountList()); + AccountsController controller(list); + list->addAccount(MinecraftAccount::createOffline("Steve")); + + QVERIFY(!controller.changeSkin(5, "/nonexistent.png", false)); + QVERIFY(!controller.changeSkin(0, "/nonexistent.png", false)); + QVERIFY(!controller.resetSkin(5)); + QVERIFY(!controller.resetSkin(0)); + QVERIFY(!controller.changeCape(5, "cape-1")); + QVERIFY(!controller.changeCape(0, "cape-1")); + } +}; + +QTEST_GUILESS_MAIN(AccountsControllerTest) + +#include "AccountsController_test.moc" diff --git a/launcher/models/BackupController.cpp b/launcher/models/BackupController.cpp new file mode 100644 index 00000000..028bd92a --- /dev/null +++ b/launcher/models/BackupController.cpp @@ -0,0 +1,316 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "BackupController.h" + +#include +#include +#include +#include +#include +#include + +#include "tasks/Task.h" +#include "tasks/TaskWatcher.h" + +namespace +{ +QString humanFileSize(qint64 bytes) +{ + if (bytes < 1024) { + return QStringLiteral("%1 B").arg(bytes); + } + if (bytes < 1024 * 1024) { + return QStringLiteral("%1 KiB").arg(bytes / 1024.0, 0, 'f', 1); + } + if (bytes < 1024LL * 1024 * 1024) { + return QStringLiteral("%1 MiB").arg(bytes / (1024.0 * 1024.0), 0, 'f', 1); + } + return QStringLiteral("%1 GiB") + .arg(bytes / (1024.0 * 1024.0 * 1024.0), 0, 'f', 2); +} +} // namespace + +/* One row per backup, newest first - BackupManager::listBackups() already + * sorts that way. A thin QAbstractListModel rather than a QVariantList + * property: the list can be long on an instance backed up often, and a list + * view should not have to rebuild every row's delegate on every refresh(). */ +class BackupListModel : public QAbstractListModel +{ + Q_OBJECT + public: + enum Roles { + NameRole = Qt::UserRole + 1, + FileNameRole, + TimestampTextRole, + SizeTextRole, + }; + + explicit BackupListModel(QObject* parent = nullptr) + : QAbstractListModel(parent) + { + } + + void setEntries(const QList& entries) + { + beginResetModel(); + m_entries = entries; + endResetModel(); + } + + QVariant data(const QModelIndex& index, int role) const override + { + const int row = index.row(); + if (row < 0 || row >= m_entries.size()) { + return {}; + } + const auto& entry = m_entries.at(row); + switch (role) { + case NameRole: + case Qt::DisplayRole: + return entry.name.isEmpty() ? entry.fileName : entry.name; + case FileNameRole: + return entry.fileName; + case TimestampTextRole: + return entry.timestamp.toString( + QStringLiteral("yyyy-MM-dd HH:mm:ss")); + case SizeTextRole: + return humanFileSize(entry.sizeBytes); + default: + return {}; + } + } + + int rowCount(const QModelIndex& parent = QModelIndex()) const override + { + return parent.isValid() ? 0 : m_entries.size(); + } + + QHash roleNames() const override + { + return { + { NameRole, "name" }, + { FileNameRole, "fileName" }, + { TimestampTextRole, "timestampText" }, + { SizeTextRole, "sizeText" }, + }; + } + + private: + QList m_entries; +}; + +/* Runs one BackupManager operation (restore/export/import/delete) off the + * GUI thread - the same QtConcurrent::run()+QFutureWatcher shape + * BackupTask.cpp uses for createBackup(), which every one of those blocks + * on for just as long on a large instance. Kept local rather than added to + * backup/ itself: nothing here needs progress reporting the way a + * compression pass does (see BackupManager::ProgressFn), only a plain + * succeeded/failed at the end. */ +class BackupJobTask : public Task +{ + Q_OBJECT + public: + using Fn = std::function; + + BackupJobTask(QString status, Fn fn, QObject* parent = nullptr) + : Task(parent), m_fn(std::move(fn)) + { + setObjectName(QStringLiteral("BackupJobTask")); + setStatus(status); + setProgress(0, 0); + } + + ~BackupJobTask() override + { + disconnect(&m_watcher, nullptr, this, nullptr); + if (m_future.isRunning()) { + m_future.waitForFinished(); + } + } + + protected: + void executeTask() override + { + connect(&m_watcher, &QFutureWatcher::finished, this, [this] { + if (m_future.result()) { + setProgress(1, 1); + emitSucceeded(); + } else { + emitFailed(tr("The operation failed. See the launcher log " + "for details.")); + } + }); + m_future = QtConcurrent::run(QThreadPool::globalInstance(), m_fn); + m_watcher.setFuture(m_future); + } + + private: + Fn m_fn; + QFuture m_future; + QFutureWatcher m_watcher; +}; + +BackupController::BackupController(InstancePtr instance, QObject* parent) + : QObject(parent), m_instance(std::move(instance)), + m_manager(m_instance->id(), m_instance->instanceRoot()) +{ + auto* list = new BackupListModel(this); + m_model.reset(list); + refresh(); + + // Mirrors WorldDataPacksController's own unlocked/runningStatusChanged + // wiring: `running` needs to track the instance live, not just at + // restoreBackup() time, so the tab can warn before the click too. + connect(m_instance.get(), &BaseInstance::runningStatusChanged, this, + &BackupController::runningChanged); +} + +BackupController::~BackupController() = default; + +QObject* BackupController::model() const +{ + return m_model.get(); +} + +bool BackupController::running() const +{ + return m_instance && m_instance->isRunning(); +} + +void BackupController::refresh() +{ + m_entries = m_manager.listBackups(); + static_cast(m_model.get())->setEntries(m_entries); +} + +QObject* BackupController::createBackup(const QString& label) +{ + // BackupManager itself is cheap to copy (three QStrings) - captured by + // value so the worker thread never touches `this`. + BackupManager manager = m_manager; + const QString labelCopy = label; + + auto* task = new BackupJobTask( + tr("Creating backup…"), + [manager, labelCopy]() mutable { + return manager.createBackup(labelCopy).isValid(); + }); + + auto* watcher = new TaskWatcher(Task::Ptr(task), this); + watcher->setTitle(tr("Backup")); + connect(watcher, &TaskWatcher::finished, this, [this](bool ok) { + if (ok) { + refresh(); + } + }); + task->start(); + return watcher; +} + +QObject* BackupController::restoreBackup(int row) +{ + if (m_instance->isRunning() || row < 0 || row >= m_entries.size()) { + return nullptr; + } + const BackupEntry entry = m_entries.at(row); + BackupManager manager = m_manager; + + auto* task = new BackupJobTask(tr("Restoring backup…"), [manager, entry]() mutable { + return manager.restoreBackup(entry); + }); + + auto* watcher = new TaskWatcher(Task::Ptr(task), this); + watcher->setTitle(tr("Restore")); + connect(watcher, &TaskWatcher::finished, this, [this](bool ok) { + if (ok) { + refresh(); + } + }); + task->start(); + return watcher; +} + +QObject* BackupController::deleteBackup(int row) +{ + if (row < 0 || row >= m_entries.size()) { + return nullptr; + } + const BackupEntry entry = m_entries.at(row); + BackupManager manager = m_manager; + + auto* task = new BackupJobTask(tr("Deleting backup…"), [manager, entry]() mutable { + return manager.deleteBackup(entry); + }); + + auto* watcher = new TaskWatcher(Task::Ptr(task), this); + watcher->setTitle(tr("Delete")); + connect(watcher, &TaskWatcher::finished, this, [this](bool ok) { + if (ok) { + refresh(); + } + }); + task->start(); + return watcher; +} + +QObject* BackupController::exportBackup(int row, const QString& destUrlOrPath) +{ + if (row < 0 || row >= m_entries.size()) { + return nullptr; + } + const BackupEntry entry = m_entries.at(row); + const QUrl url(destUrlOrPath); + const QString dest = url.isLocalFile() ? url.toLocalFile() : destUrlOrPath; + BackupManager manager = m_manager; + + auto* task = new BackupJobTask(tr("Exporting backup…"), [manager, entry, dest]() mutable { + return manager.exportBackup(entry, dest); + }); + + auto* watcher = new TaskWatcher(Task::Ptr(task), this); + watcher->setTitle(tr("Export")); + task->start(); + return watcher; +} + +QObject* BackupController::importBackup(const QString& fileUrlOrPath, + const QString& label) +{ + const QUrl url(fileUrlOrPath); + const QString src = url.isLocalFile() ? url.toLocalFile() : fileUrlOrPath; + BackupManager manager = m_manager; + const QString labelCopy = label; + + auto* task = new BackupJobTask(tr("Importing backup…"), [manager, src, labelCopy]() mutable { + return manager.importBackup(src, labelCopy).isValid(); + }); + + auto* watcher = new TaskWatcher(Task::Ptr(task), this); + watcher->setTitle(tr("Import")); + connect(watcher, &TaskWatcher::finished, this, [this](bool ok) { + if (ok) { + refresh(); + } + }); + task->start(); + return watcher; +} + +#include "BackupController.moc" diff --git a/launcher/models/BackupController.h b/launcher/models/BackupController.h new file mode 100644 index 00000000..0366ae84 --- /dev/null +++ b/launcher/models/BackupController.h @@ -0,0 +1,90 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include + +#include "BaseInstance.h" +#include "backup/BackupManager.h" + +class QAbstractListModel; + +/* + * QML-facing instance backups - the widget-free replacement for BackupPage. + * + * Every operation that can take a while (create, restore, export, import, + * delete - BackupManager itself blocks the calling thread for all five, see + * its class comment) runs on a worker thread behind a TaskWatcher, the same + * pattern QmlShell::duplicateInstance()/NewInstanceController::importFrom() + * use for InstanceCopyTask/InstanceImportTask: this page must not freeze the + * GUI thread zipping or unzipping a multi-gigabyte instance. + * + * Created once per InstanceDetails (InstanceDetails::backups()), for every + * instance type - a backup is just a zip of the instance folder, nothing + * about it needs the instance to be Minecraft-backed. + */ +class BackupController : public QObject +{ + Q_OBJECT + + /// QAbstractListModel of the instance's backups, newest first - roles + /// "name" (label, falling back to the file name), "fileName", + /// "timestampText" (yyyy-MM-dd HH:mm:ss) and "sizeText" (human size). + Q_PROPERTY(QObject* model READ model CONSTANT) + /// Whether the instance is currently running - the same condition + /// restoreBackup() itself refuses on, exposed so the tab can warn + /// before the click rather than only after it, mirroring + /// WorldDataPacksController::unlocked(). + Q_PROPERTY(bool running READ running NOTIFY runningChanged) + + public: + explicit BackupController(InstancePtr instance, QObject* parent = nullptr); + ~BackupController() override; + + QObject* model() const; + bool running() const; + + Q_INVOKABLE void refresh(); + /// Starts a new backup; returns a TaskWatcher. + Q_INVOKABLE QObject* createBackup(const QString& label); + /// Null if @p row is out of range or the instance is currently + /// running - restoring would fight a live process for the same files + /// (same guard BackupPage's own restore button applies). See + /// `running` above for surfacing that to the user before the click. + Q_INVOKABLE QObject* restoreBackup(int row); + Q_INVOKABLE QObject* deleteBackup(int row); + /// @p destUrlOrPath: a file:// URL (as a QML FileDialog save-mode + /// picker hands out) or a plain local path. + Q_INVOKABLE QObject* exportBackup(int row, const QString& destUrlOrPath); + /// @p fileUrlOrPath: likewise, from an open-mode FileDialog. + Q_INVOKABLE QObject* importBackup(const QString& fileUrlOrPath, + const QString& label); + + signals: + void runningChanged(); + + private: + InstancePtr m_instance; + BackupManager m_manager; + std::unique_ptr m_model; + QList m_entries; +}; diff --git a/launcher/models/ContentBrowser.cpp b/launcher/models/ContentBrowser.cpp new file mode 100644 index 00000000..73ed221a --- /dev/null +++ b/launcher/models/ContentBrowser.cpp @@ -0,0 +1,697 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "ContentBrowser.h" + +#include +#include +#include +#include +#include + +#include "BuildConfig.h" +#include "minecraft/MinecraftInstance.h" +#include "minecraft/PackProfile.h" +#include "minecraft/mod/ModFolderModel.h" +#include "minecraft/mod/ModMetadataIndex.h" +#include "modplatform/ContentDownloadTask.h" +#include "modplatform/DependencyResolver.h" +#include "modplatform/ModDownloadTypes.h" +#include "modplatform/ModInstallConflictAnalyzer.h" +#include "modplatform/flame/FlameContentModel.h" +#include "modplatform/modrinth/ModrinthContentModel.h" +#include "tasks/TaskWatcher.h" + +namespace +{ + + ModPlatform::ContentType typeFromString(const QString& s) + { + if (s == QStringLiteral("resourcepacks")) { + return ModPlatform::ContentType::ResourcePack; + } + if (s == QStringLiteral("shaderpacks")) { + return ModPlatform::ContentType::ShaderPack; + } + if (s == QStringLiteral("datapacks")) { + return ModPlatform::ContentType::DataPack; + } + return ModPlatform::ContentType::Mod; + } + + /* Converts a SelectedMod/DependencyInfo pair of fields into one + * DownloadItem - the same field-by-field copy DownloadSummaryDialog's + * constructor does for each of its two loops (ui/dialogs/ + * DownloadSummaryDialog.cpp), pulled out here because this runs the + * same conversion with nobody left to tick a box in between. */ + ModPlatform::DownloadItem toDownloadItem(const ModPlatform::SelectedMod& mod) + { + ModPlatform::DownloadItem item; + item.name = mod.name; + item.fileName = mod.fileName; + item.downloadUrl = mod.downloadUrl; + item.sha1 = mod.sha1; + item.fileSize = mod.fileSize; + item.isDependency = false; + item.platform = mod.platform; + item.projectId = mod.projectId; + item.versionId = mod.versionId; + item.slug = mod.slug; + item.browserDownloadOnly = mod.browserDownloadOnly; + return item; + } + + ModPlatform::DownloadItem + toDownloadItem(const ModPlatform::DependencyInfo& dep) + { + ModPlatform::DownloadItem item; + item.name = dep.name; + item.fileName = dep.fileName; + item.downloadUrl = dep.downloadUrl; + item.sha1 = dep.sha1; + item.fileSize = dep.fileSize; + item.isDependency = true; + item.platform = dep.platform; + item.projectId = dep.projectId; + item.versionId = dep.versionId; + item.slug = dep.slug; + item.browserDownloadOnly = dep.browserDownloadOnly; + return item; + } + +} // namespace + +/* + * Runs the same three steps ModFolderPage::installSelection() + + * reviewAndInstall() run for one mod picked in the widget dialog - resolve + * dependencies, settle conflicts against what is on disk, download - minus + * the two dialogs in between, since there is nobody here to answer them. + * + * The defaults those dialogs would otherwise ask about: + * - Required dependencies: always installed. DependencyResolver already + * only ever follows "required" relations (see processCFFileDeps()/ + * processMRVersionDeps() - optional and embedded ones are either + * skipped outright or folded into the parent's own file), so + * everything resolvedDependencies() hands back already passed that + * bar. + * - A dependency whose project is already installed at some other + * version (DependencyInfo::maybeInstalled): left alone, exactly the + * unticked default DownloadSummaryDialog::appendRow() starts such a + * row at ("Unticked because a version of this is already installed"). + * - Name/file-name conflicts against what is on disk + * (ModInstallConflictAnalyzer): resolved exactly as the widget + * resolves them once a row survives to the plan - AlreadyInstalled is + * dropped, everything else becomes a replace-in-place. Nothing here + * asks about it either; the widget's own summary dialog does not, so + * there is no missing confirmation step to replicate. + * + * A resolver failure or abort (network hiccup, nothing more) does not fail + * the whole install - the widget's Skip button does not either; whatever + * was resolved before it is used and the download proceeds. + */ +class ContentBrowserInstallTask : public Task +{ + Q_OBJECT + + public: + ContentBrowserInstallTask(const ModPlatform::SelectedMod& mod, + const QString& mcVersion, const QString& loader, + const QString& targetDir, + std::shared_ptr metadataIndex, + bool resolveDependencies, + QObject* parent = nullptr) + : Task(parent), m_mod(mod), m_mcVersion(mcVersion), m_loader(loader), + m_targetDir(targetDir), m_metadataIndex(std::move(metadataIndex)), + m_resolveDependencies(resolveDependencies) + { + } + + bool canAbort() const override + { + if (m_download) { + return m_download->canAbort(); + } + if (m_resolver) { + return m_resolver->canAbort(); + } + return false; + } + + public slots: + bool abort() override + { + if (m_aborted) { + return true; + } + m_aborted = true; + + /* Whichever child is currently doing work absorbs the abort + * request. Its succeeded()/failed() is routed through the + * m_aborted check in onResolved()/the download connections below + * rather than straight into our own emitSucceeded()/emitFailed(), + * so the abort below is what settles our own final state. */ + if (m_download && m_download->isRunning() && m_download->canAbort()) { + m_download->abort(); + } else if (m_resolver && m_resolver->isRunning() && + m_resolver->canAbort()) { + m_resolver->abort(); + } + + if (isRunning()) { + emitAborted(); + } + return true; + } + + protected: + void executeTask() override + { + if (!m_resolveDependencies) { + onResolved(); + return; + } + + setStatus(tr("Resolving dependencies...")); + m_resolver = + new DependencyResolver({m_mod}, m_mcVersion, m_loader, this); + m_resolver->setInstalledIndex(m_metadataIndex); + connect(m_resolver, &Task::status, this, &Task::setStatus); + connect(m_resolver, &Task::progress, this, &Task::setProgress); + propagateStepsFrom(m_resolver); + connect(m_resolver, &Task::succeeded, this, + &ContentBrowserInstallTask::onResolved); + connect(m_resolver, &Task::failed, this, + [this](QString) { onResolved(); }); + m_resolver->start(); + } + + private slots: + void onResolved() + { + if (m_aborted) { + return; + } + + QList items; + items.append(toDownloadItem(m_mod)); + + if (m_resolver) { + for (const auto& dep : m_resolver->resolvedDependencies()) { + if (dep.maybeInstalled) { + /* Matches the widget's own default (see the class + * comment): left alone rather than replaced. */ + continue; + } + items.append(toDownloadItem(dep)); + } + } + + const auto decisions = + ModInstallConflictAnalyzer::analyze(items, m_metadataIndex); + const auto plan = ModInstallConflictAnalyzer::toDownloadPlan(decisions); + + if (plan.isEmpty()) { + emitSucceeded(); + return; + } + + setStatus(tr("Downloading %1 file(s)...").arg(plan.size())); + m_download = new ContentDownloadTask(plan, m_targetDir, this); + m_download->setMetadataIndex(m_metadataIndex); + connect(m_download, &Task::status, this, &Task::setStatus); + connect(m_download, &Task::progress, this, &Task::setProgress); + propagateStepsFrom(m_download); + connect(m_download, &Task::succeeded, this, [this] { + if (!m_aborted) { + emitSucceeded(); + } + }); + connect(m_download, &Task::failed, this, [this](QString reason) { + if (!m_aborted) { + emitFailed(reason); + } + }); + m_download->start(); + } + + private: + ModPlatform::SelectedMod m_mod; + QString m_mcVersion; + QString m_loader; + QString m_targetDir; + std::shared_ptr m_metadataIndex; + bool m_resolveDependencies; + + /* Owned via QObject parentage (parent is `this`), not shared_qobject_ptr: + * neither is ever swapped out from under itself the way ContentDownloadTask's + * own m_netJob is, so there is nothing for a Ptr's reset-then-replace to + * protect against here. */ + DependencyResolver* m_resolver = nullptr; + ContentDownloadTask* m_download = nullptr; + bool m_aborted = false; +}; + +ContentBrowser::ContentBrowser(MinecraftInstance* instance, QObject* parent) + : QObject(parent), m_instance(instance) +{ + /* Same detection DownloadContentDialog::detectInstanceProfile() does. */ + if (auto profile = m_instance ? m_instance->getPackProfile() : nullptr) { + m_mcVersion = profile->getComponentVersion("net.minecraft"); + m_loaderType = profile->primaryModLoader(); + } +} + +ContentBrowser::~ContentBrowser() = default; + +void ContentBrowser::setProvider(const QString& provider) +{ + const QString normalized = provider == QStringLiteral("curseforge") + ? provider + : QStringLiteral("modrinth"); + if (m_provider == normalized) { + return; + } + m_provider = normalized; + emit providerChanged(); + emit resultsChanged(); + emit sortOptionsChanged(); + m_sortIndex = 0; + emit sortIndexChanged(); + emit searchingChanged(); + emit canFetchMoreChanged(); + emit countChanged(); + setError(QString()); +} + +bool ContentBrowser::curseForgeKeyMissing() const +{ + return BuildConfig.CURSEFORGE_API_KEY.isEmpty(); +} + +QString ContentBrowser::contentType() const +{ + return ModPlatform::contentTypeFolderName(m_contentType); +} + +void ContentBrowser::setContentType(const QString& contentType) +{ + const auto type = typeFromString(contentType); + if (m_contentType == type) { + return; + } + m_contentType = type; + emit contentTypeChanged(); + emit resultsChanged(); + emit sortOptionsChanged(); + m_sortIndex = 0; + emit sortIndexChanged(); + emit searchingChanged(); + emit canFetchMoreChanged(); + emit countChanged(); + setError(QString()); +} + +void ContentBrowser::setQuery(const QString& query) +{ + if (m_query == query) { + return; + } + m_query = query; + emit queryChanged(); +} + +void ContentBrowser::setSortIndex(int index) +{ + if (m_sortIndex == index) { + return; + } + m_sortIndex = index; + emit sortIndexChanged(); +} + +QVariantList +ContentBrowser::sortOptionsFor(const QList& methods) +{ + QVariantList result; + for (int i = 0; i < methods.size(); ++i) { + QVariantMap entry; + entry[QStringLiteral("id")] = i; + entry[QStringLiteral("label")] = methods.at(i).readableName; + result.append(entry); + } + return result; +} + +QVariantList ContentBrowser::sortOptions() const +{ + auto* model = currentModel(); + return model ? sortOptionsFor(model->sortingMethods()) : QVariantList(); +} + +bool ContentBrowser::searching() const +{ + auto* model = currentModel(); + return model && model->isSearching(); +} + +bool ContentBrowser::canFetchMore() const +{ + auto* model = currentModel(); + return model && model->canFetchMore(QModelIndex()); +} + +int ContentBrowser::count() const +{ + auto* model = currentModel(); + return model ? model->rowCount(QModelIndex()) : 0; +} + +void ContentBrowser::setError(const QString& error) +{ + if (m_error == error) { + return; + } + m_error = error; + emit errorChanged(); +} + +QObject* ContentBrowser::results() const +{ + return currentModel(); +} + +QString ContentBrowser::modelKey(const QString& provider, + ModPlatform::ContentType type) const +{ + return provider + QStringLiteral(":") + + ModPlatform::contentTypeFolderName(type); +} + +std::shared_ptr +ContentBrowser::folderModelFor(ModPlatform::ContentType type) const +{ + if (!m_instance) { + return nullptr; + } + switch (type) { + case ModPlatform::ContentType::Mod: + return m_instance->loaderModList(); + case ModPlatform::ContentType::ResourcePack: + return m_instance->resourcePackList(); + case ModPlatform::ContentType::ShaderPack: + return m_instance->shaderPackList(); + case ModPlatform::ContentType::DataPack: + return m_instance->dataPackList(); + } + return nullptr; +} + +ContentProviderModel* ContentBrowser::ensureModel(const QString& provider, + ModPlatform::ContentType type) +{ + const QString key = modelKey(provider, type); + auto it = m_models.constFind(key); + if (it != m_models.constEnd()) { + return it.value(); + } + + /* Same filters DownloadContentDialog::buildPages() builds: this + * instance's own Minecraft version, and its loader for content that is + * loader-specific. */ + ModPlatform::SearchFilters filters; + filters.mcVersions = ModPlatform::singleVersionList(m_mcVersion); + if (!m_loaderType.isEmpty() && ModPlatform::contentTypeUsesLoader(type)) { + filters.loaders = QStringList{m_loaderType}; + } + + ContentProviderModel* model = nullptr; + if (provider == QStringLiteral("curseforge")) { + model = new FlameContentModel(type, filters, this); + } else { + model = new ModrinthContentModel(type, filters, this); + } + + if (auto folder = folderModelFor(type)) { + model->setInstalledIndex(folder->metadataIndex()); + } + + connectModel(model); + m_models.insert(key, model); + return model; +} + +ContentProviderModel* ContentBrowser::currentModel() const +{ + return const_cast(this)->ensureModel(m_provider, + m_contentType); +} + +void ContentBrowser::connectModel(ContentProviderModel* model) +{ + connect(model, &ContentProviderModel::searchStateChanged, this, + &ContentBrowser::onModelSearchStateChanged); + connect(model, &ContentProviderModel::entryUpdated, this, + &ContentBrowser::onEntryUpdated); +} + +void ContentBrowser::onModelSearchStateChanged() +{ + auto* model = qobject_cast(sender()); + if (!model || model != currentModel()) { + /* Some other (provider, contentType) pair, visited earlier and + * still finishing up in the background - nothing currently shown + * needs to move for it. */ + return; + } + + emit searchingChanged(); + emit canFetchMoreChanged(); + emit countChanged(); + if (!model->isSearching()) { + setError(model->lastError()); + } +} + +void ContentBrowser::search() +{ + if (m_provider == QStringLiteral("curseforge") && curseForgeKeyMissing()) { + setError(tr("This build has no CurseForge API key configured, so " + "CurseForge cannot be searched.")); + return; + } + + auto* model = currentModel(); + if (!model) { + return; + } + /* Cleared here rather than left for onModelSearchStateChanged() to + * pick up once this concludes - a stale error from a previous search + * should not still be on screen while a new one is in flight. */ + setError(QString()); + model->search(m_query, m_sortIndex); +} + +void ContentBrowser::fetchMore() +{ + auto* model = currentModel(); + if (!model || !model->canFetchMore(QModelIndex())) { + return; + } + model->fetchMore(QModelIndex()); +} + +bool ContentBrowser::isVersionCompatible( + const ModPlatform::ContentVersion& version, const QString& mcVersion, + const QString& loader) +{ + /* A version that does not state a game version/loader of its own is + * accepted for that half of the check rather than flagged incompatible + * - plenty of provider replies simply do not say (see the field + * comments on ContentVersion), and this is already only a client-side + * second opinion on filtering the provider's own query already asked + * for. */ + const bool mcOk = mcVersion.isEmpty() || version.gameVersions.isEmpty() || + version.gameVersions.contains(mcVersion); + const bool loaderOk = loader.isEmpty() || version.loaders.isEmpty() || + version.loaders.contains(loader, + Qt::CaseInsensitive); + return mcOk && loaderOk; +} + +void ContentBrowser::applyVersionsFromProject( + const ModPlatform::IndexedProject* project) +{ + QVariantList versions; + + if (project) { + auto sorted = project->versions; + /* Newest first. Modrinth's own reply already comes this way, but + * CurseForge's does not promise any order at all - the same reason + * ModPlatform::newestCurseForgeFile() cannot just take the first + * entry either - so this is sorted explicitly rather than trusted + * from either provider. */ + std::stable_sort( + sorted.begin(), sorted.end(), + [](const ModPlatform::ContentVersion& a, + const ModPlatform::ContentVersion& b) { + const QDateTime dateA = + QDateTime::fromString(a.datePublished, Qt::ISODate); + const QDateTime dateB = + QDateTime::fromString(b.datePublished, Qt::ISODate); + if (dateA.isValid() && dateB.isValid()) { + return dateA > dateB; + } + /* An unknown date sorts after a known one; two unknown + * dates keep whatever order they arrived in. */ + return dateA.isValid(); + }); + + for (const auto& version : sorted) { + QVariantMap entry; + entry[QStringLiteral("id")] = version.versionId; + entry[QStringLiteral("name")] = version.name; + entry[QStringLiteral("versionNumber")] = version.versionNumber; + entry[QStringLiteral("gameVersions")] = + QVariant::fromValue(version.gameVersions); + entry[QStringLiteral("loaders")] = + QVariant::fromValue(version.loaders); + entry[QStringLiteral("datePublished")] = version.datePublished; + entry[QStringLiteral("isCompatible")] = + isVersionCompatible(version, m_mcVersion, m_loaderType); + versions.append(entry); + } + } + + m_versions = versions; + m_versionsLoading = false; + emit versionsChanged(); + emit versionsLoadingChanged(); +} + +void ContentBrowser::onEntryUpdated(int row) +{ + auto* model = qobject_cast(sender()); + if (!model || model != m_versionsModel || row != m_versionsRow) { + /* An answer for a row (or a model) the caller has since moved on + * from - see ContentProviderModel::applyVersions() for the same + * idea on the model's own side. */ + return; + } + applyVersionsFromProject(model->projectAt(row)); +} + +void ContentBrowser::loadVersions(int row) +{ + auto* model = currentModel(); + const auto* project = model ? model->projectAt(row) : nullptr; + if (!project) { + m_versionsModel = nullptr; + m_versionsRow = -1; + applyVersionsFromProject(nullptr); + return; + } + + m_versionsModel = model; + m_versionsRow = row; + + if (project->versionsLoaded) { + applyVersionsFromProject(project); + return; + } + + m_versions = QVariantList(); + m_versionsLoading = true; + emit versionsChanged(); + emit versionsLoadingChanged(); + + model->loadEntry(row); +} + +QObject* ContentBrowser::install(int row, const QString& versionId) +{ + auto* model = currentModel(); + const auto* project = model ? model->projectAt(row) : nullptr; + if (!project) { + qWarning() << "ContentBrowser::install: no such row" << row; + return nullptr; + } + + const ModPlatform::ContentVersion* version = nullptr; + for (const auto& candidate : project->versions) { + if (candidate.versionId == versionId) { + version = &candidate; + break; + } + } + if (!version) { + qWarning() << "ContentBrowser::install: version" << versionId + << "not known for" << project->name + << "- call loadVersions() first"; + return nullptr; + } + + auto folder = folderModelFor(m_contentType); + if (!folder) { + qWarning() << "ContentBrowser::install: no folder for this content " + "type on this instance"; + return nullptr; + } + + ModPlatform::SelectedMod mod; + mod.name = project->name; + mod.projectId = project->projectId; + mod.versionId = version->versionId; + mod.slug = project->slug; + mod.fileName = version->fileName; + mod.downloadUrl = version->downloadUrl; + mod.sha1 = version->sha1; + mod.fileSize = version->fileSize; + mod.platform = model->platformId(); + mod.mcVersion = m_mcVersion; + mod.loaders = m_loaderType; + mod.versionType = version->versionType; + mod.browserDownloadOnly = version->browserDownloadOnly; + + /* Dependencies are a mod-only idea - see ModFolderPage:: + * installSelection()'s own ContentType::Mod check. */ + const bool resolveDependencies = + m_contentType == ModPlatform::ContentType::Mod; + const QString loaderForResolver = + ModPlatform::contentTypeUsesLoader(m_contentType) ? m_loaderType + : QString(); + + auto* task = new ContentBrowserInstallTask( + mod, m_mcVersion, loaderForResolver, folder->dir().absolutePath(), + folder->metadataIndex(), resolveDependencies, this); + + auto* watcher = new TaskWatcher(Task::Ptr(task), this); + watcher->setTitle(project->name); + + /* Refresh the instance's content list afterwards, exactly the way + * ModFolderPage::reviewAndInstall() calls m_mods->update() once the + * download task is done - on success, on failure (some files may have + * landed before the rest failed) and on abort alike. */ + connect(watcher, &TaskWatcher::finished, this, + [folder](bool) { folder->update(); }); + + task->start(); + return watcher; +} + +#include "ContentBrowser.moc" diff --git a/launcher/models/ContentBrowser.h b/launcher/models/ContentBrowser.h new file mode 100644 index 00000000..14808fe6 --- /dev/null +++ b/launcher/models/ContentBrowser.h @@ -0,0 +1,260 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include +#include + +#include "modplatform/ContentType.h" + +class MinecraftInstance; +class ModFolderModel; +class ContentProviderModel; + +namespace ModPlatform +{ + struct ContentVersion; + struct SortingMethod; + struct IndexedProject; +} // namespace ModPlatform + +/* + * QML-facing bridge to CurseForge/Modrinth content search and install for + * one instance - the widget-free replacement for DownloadContentDialog + + * ModFolderPage::installSelection()/reviewAndInstall(). + * + * Search itself is exactly what the widget dialog does: one + * ContentProviderModel per provider, configured with this instance's + * Minecraft version and loader the same way DownloadContentDialog:: + * detectInstanceProfile()/buildPages() does, one model kept (and its + * search state preserved) per provider/content-type pair actually visited. + * + * install() collapses the widget's three-dialog "resolve dependencies -> + * review -> confirm conflicts" pipeline into one automatic decision, since + * there is no dialog here to ask through: required dependencies are always + * installed, a dependency whose project is already present at some other + * version is left alone (the same default the review dialog's tick boxes + * start at), and a name/file-name conflict against what is already on disk + * is resolved exactly as the conflict analyzer already decides for the + * widget (replace in place). See ContentBrowserInstallTask in the .cpp. + * + * Created lazily by InstanceDetails::contentBrowser() and parented to it + * (see that class), so opening an instance page never talks to the network + * on its own - only search()/loadVersions()/install() do. + */ +class ContentBrowser : public QObject +{ + Q_OBJECT + + /// "modrinth" | "curseforge". Defaults to "modrinth" - the provider the + /// widget dialog puts first, and the one that never needs an API key. + Q_PROPERTY(QString provider READ provider WRITE setProvider NOTIFY + providerChanged) + /// Whether this build has no CurseForge API key compiled in + /// (BuildConfig::CURSEFORGE_API_KEY) - every CurseForge request 403s + /// without one. search() refuses to run against CurseForge while this + /// is true rather than start a request that cannot succeed; QML can + /// use it to grey the provider out ahead of time instead of waiting to + /// be told. + Q_PROPERTY(bool curseForgeKeyMissing READ curseForgeKeyMissing CONSTANT) + /// "mods" (default) | "resourcepacks" | "shaderpacks" | "datapacks". + Q_PROPERTY(QString contentType READ contentType WRITE setContentType + NOTIFY contentTypeChanged) + Q_PROPERTY(QString query READ query WRITE setQuery NOTIFY queryChanged) + /// Index into sortOptions() for the *current* provider - not a fixed + /// meaning across both, since CurseForge and Modrinth do not offer the + /// same sorts (see ModPlatform::ContentApi::sortingMethods()). Changing + /// `provider` resets this back to 0 and emits sortOptionsChanged(). + Q_PROPERTY( + int sortIndex READ sortIndex WRITE setSortIndex NOTIFY sortIndexChanged) + /// QVariantList of {id: int, label: string}, for the current provider. + /// Deliberately NOTIFY rather than CONSTANT: CurseForge offers eight + /// sorts, Modrinth five, in different orders with different meanings, + /// so a single fixed list would either misrepresent one of them or + /// send the wrong sort - see the .cpp for the fuller reasoning. + Q_PROPERTY( + QVariantList sortOptions READ sortOptions NOTIFY sortOptionsChanged) + Q_PROPERTY(bool searching READ searching NOTIFY searchingChanged) + Q_PROPERTY( + bool canFetchMore READ canFetchMore NOTIFY canFetchMoreChanged) + Q_PROPERTY(int count READ count NOTIFY countChanged) + /// Empty on success, or while nothing has searched yet. + Q_PROPERTY(QString error READ error NOTIFY errorChanged) + /// The ContentProviderModel for the current provider/contentType pair - + /// see ProjectItemRole (ContentProviderModel.h) plus "projectId" and + /// "logoKey" for its roleNames(). Swaps to a different (already + /// parented, so still safe once exposed to QML - see + /// InstanceDetails::contentBrowser()) model instance when provider or + /// contentType changes; each provider/contentType pair keeps its own + /// model (and search results) for as long as this browser lives. + Q_PROPERTY(QObject* results READ results NOTIFY resultsChanged) + /// QVariantList of {id, name, versionNumber, gameVersions, loaders, + /// datePublished, isCompatible}, newest first, for the row loadVersions() + /// was last called with. + Q_PROPERTY(QVariantList versions READ versions NOTIFY versionsChanged) + Q_PROPERTY(bool versionsLoading READ versionsLoading NOTIFY + versionsLoadingChanged) + + public: + explicit ContentBrowser(MinecraftInstance* instance, + QObject* parent = nullptr); + ~ContentBrowser() override; + + QString provider() const + { + return m_provider; + } + void setProvider(const QString& provider); + bool curseForgeKeyMissing() const; + + QString contentType() const; + void setContentType(const QString& contentType); + + QString query() const + { + return m_query; + } + void setQuery(const QString& query); + + int sortIndex() const + { + return m_sortIndex; + } + void setSortIndex(int index); + QVariantList sortOptions() const; + + bool searching() const; + bool canFetchMore() const; + int count() const; + QString error() const + { + return m_error; + } + + QObject* results() const; + + QVariantList versions() const + { + return m_versions; + } + bool versionsLoading() const + { + return m_versionsLoading; + } + + /// Starts a fresh search from query()/sortIndex() against the current + /// provider/contentType. Repeating the current query is cheap - see + /// ContentProviderModel::search(). + Q_INVOKABLE void search(); + /// Fetches the next page of the current search; a no-op while already + /// searching or when canFetchMore() is false. + Q_INVOKABLE void fetchMore(); + /// Fetches (or re-shows, if already loaded) the compatible versions of + /// results() row `row`, filling `versions`. + Q_INVOKABLE void loadVersions(int row); + /// Resolves required dependencies, settles conflicts against what is + /// already installed and downloads everything, exactly the way the + /// widget dialog does once its own dialogs are out of the way. Returns + /// a TaskWatcher (parented to this browser - InstanceDetails/QmlShell + /// pin it for QML the same way they do every other child object), or + /// null if `row` or `versionId` do not name anything installable + /// (call loadVersions() first). + Q_INVOKABLE QObject* install(int row, const QString& versionId); + + /// Whether `version` may run on `mcVersion`/`loader`. A version with no + /// stated game versions or no stated loaders is accepted for that half + /// of the check - plenty of provider replies simply do not say, and + /// refusing them would flag entries the provider's own search filter + /// already let through as "incompatible" for no good reason. Public and + /// static so it can be unit tested without a live search. + static bool isVersionCompatible(const ModPlatform::ContentVersion& version, + const QString& mcVersion, + const QString& loader); + /// ModPlatform::SortingMethod -> {id: , label: }. + /// Public and static for the same reason as isVersionCompatible(). + static QVariantList + sortOptionsFor(const QList& methods); + + signals: + void providerChanged(); + void contentTypeChanged(); + void queryChanged(); + void sortIndexChanged(); + void sortOptionsChanged(); + void searchingChanged(); + void canFetchMoreChanged(); + void countChanged(); + void errorChanged(); + void resultsChanged(); + void versionsChanged(); + void versionsLoadingChanged(); + + private slots: + void onModelSearchStateChanged(); + void onEntryUpdated(int row); + + private: + QString modelKey(const QString& provider, + ModPlatform::ContentType type) const; + ContentProviderModel* currentModel() const; + ContentProviderModel* ensureModel(const QString& provider, + ModPlatform::ContentType type); + void connectModel(ContentProviderModel* model); + std::shared_ptr + folderModelFor(ModPlatform::ContentType type) const; + void setError(const QString& error); + /// Rebuilds `versions` from `project` (null clears it) and settles + /// `versionsLoading` - shared by loadVersions() itself, for a project + /// whose versions were already loaded, and by onEntryUpdated(), for one + /// that just finished loading. + void applyVersionsFromProject(const ModPlatform::IndexedProject* project); + + private: + MinecraftInstance* m_instance; + /// This instance's Minecraft version / primary loader, read once at + /// construction the same way DownloadContentDialog:: + /// detectInstanceProfile() does - an instance's loader can change while + /// the page is open, but so can it while the widget dialog is open, and + /// neither reacts to that either. + QString m_mcVersion; + QString m_loaderType; + + QString m_provider = QStringLiteral("modrinth"); + ModPlatform::ContentType m_contentType = ModPlatform::ContentType::Mod; + QString m_query; + int m_sortIndex = 0; + QString m_error; + + /// modelKey(provider, type) -> model. Owned via QObject parentage + /// (parent is `this`); kept for as long as this browser lives so + /// flipping providers does not lose a search already in progress. + QHash m_models; + + QVariantList m_versions; + bool m_versionsLoading = false; + /// The model and row `versions`/`versionsLoading` answer for, so a + /// late reply for a row the user has since moved away from (or for a + /// model that is no longer current) is not applied. + ContentProviderModel* m_versionsModel = nullptr; + int m_versionsRow = -1; +}; diff --git a/launcher/models/ContentBrowser_test.cpp b/launcher/models/ContentBrowser_test.cpp new file mode 100644 index 00000000..2044409e --- /dev/null +++ b/launcher/models/ContentBrowser_test.cpp @@ -0,0 +1,144 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include + +#include "models/ContentBrowser.h" + +#include "modplatform/ContentApi.h" +#include "modplatform/ContentProviderModel.h" + +/* Both functions under test are public and static, and touch neither the + * network nor a MinecraftInstance - see the class comment on + * ContentBrowser::isVersionCompatible()/sortOptionsFor(). Constructing a + * full ContentBrowser (which needs a MinecraftInstance) is impractical + * here, the same reason models/InstanceDetails_test.cpp only exercises + * InstanceLogBridge. */ +class ContentBrowserTest : public QObject +{ + Q_OBJECT + + private slots: + + void test_CompatibleWhenEverythingMatches() + { + ModPlatform::ContentVersion version; + version.gameVersions = {"1.20.1", "1.20.4"}; + version.loaders = {"fabric", "quilt"}; + + QVERIFY(ContentBrowser::isVersionCompatible(version, "1.20.1", + "fabric")); + } + + void test_IncompatibleGameVersion() + { + ModPlatform::ContentVersion version; + version.gameVersions = {"1.19.2"}; + version.loaders = {"fabric"}; + + QVERIFY(!ContentBrowser::isVersionCompatible(version, "1.20.1", + "fabric")); + } + + void test_IncompatibleLoader() + { + ModPlatform::ContentVersion version; + version.gameVersions = {"1.20.1"}; + version.loaders = {"forge"}; + + QVERIFY(!ContentBrowser::isVersionCompatible(version, "1.20.1", + "fabric")); + } + + void test_LoaderMatchIsCaseInsensitive() + { + ModPlatform::ContentVersion version; + version.gameVersions = {"1.20.1"}; + version.loaders = {"Fabric"}; + + QVERIFY(ContentBrowser::isVersionCompatible(version, "1.20.1", + "fabric")); + } + + /* A version that states neither is accepted for whichever half it is + * silent about, rather than flagged incompatible - see the field + * comments on ModPlatform::ContentVersion. */ + void test_UnstatedFieldsAreAccepted() + { + ModPlatform::ContentVersion version; + QVERIFY(ContentBrowser::isVersionCompatible(version, "1.20.1", + "fabric")); + + ModPlatform::ContentVersion partial; + partial.gameVersions = {"1.20.1"}; + /* No loaders stated at all - a library mod, say. */ + QVERIFY(ContentBrowser::isVersionCompatible(partial, "1.20.1", + "fabric")); + } + + void test_EmptyCallerArgumentsAreAccepted() + { + /* An instance with no detected loader (detectInstanceProfile() + * came up empty) searches without a loader filter - the same + * version should not then be flagged incompatible for a loader + * nobody named. */ + ModPlatform::ContentVersion version; + version.gameVersions = {"1.20.1"}; + version.loaders = {"forge"}; + QVERIFY(ContentBrowser::isVersionCompatible(version, "1.20.1", "")); + QVERIFY(ContentBrowser::isVersionCompatible(version, "", "forge")); + } + + void test_SortOptionsForModrinth() + { + /* Mirrors ModrinthApi::sortingMethods() (five string-valued + * sorts) - the point being that `id` is the position in the list, + * not the provider's own apiValue, since that is what + * ContentProviderModel::search()'s sortIndex parameter expects. */ + const QList methods = { + {"relevance", "Sort by Relevance"}, + {"downloads", "Sort by Downloads"}, + {"follows", "Sort by Follows"}, + {"newest", "Sort by Newest"}, + {"updated", "Sort by Last Updated"}, + }; + + const QVariantList options = ContentBrowser::sortOptionsFor(methods); + QCOMPARE(options.size(), 5); + + const QVariantMap first = options.at(0).toMap(); + QCOMPARE(first.value("id").toInt(), 0); + QCOMPARE(first.value("label").toString(), + QString("Sort by Relevance")); + + const QVariantMap last = options.at(4).toMap(); + QCOMPARE(last.value("id").toInt(), 4); + QCOMPARE(last.value("label").toString(), + QString("Sort by Last Updated")); + } + + void test_SortOptionsForEmptyListIsEmpty() + { + QVERIFY(ContentBrowser::sortOptionsFor({}).isEmpty()); + } +}; + +QTEST_GUILESS_MAIN(ContentBrowserTest) + +#include "ContentBrowser_test.moc" diff --git a/launcher/models/IdSelectionModel.cpp b/launcher/models/IdSelectionModel.cpp new file mode 100644 index 00000000..d988dcdb --- /dev/null +++ b/launcher/models/IdSelectionModel.cpp @@ -0,0 +1,119 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "IdSelectionModel.h" + +IdSelectionModel::IdSelectionModel(QObject* parent) : QObject(parent) {} + +int IdSelectionModel::count() const +{ + return m_selected.count(); +} + +QStringList IdSelectionModel::selectedIds() const +{ + return QStringList(m_selected.values()); +} + +QString IdSelectionModel::currentId() const +{ + return m_currentId; +} + +bool IdSelectionModel::setCurrentIdInternal(const QString& id) +{ + if (m_currentId == id) { + return false; + } + m_currentId = id; + Q_EMIT currentIdChanged(); + return true; +} + +void IdSelectionModel::select(const QString& id) +{ + // Empty is the "nothing selected" sentinel currentId() uses; selecting + // it would make an empty selection indistinguishable from one that + // contains it. + if (id.isEmpty() || m_selected.contains(id)) { + return; + } + m_selected.insert(id); + setCurrentIdInternal(id); + Q_EMIT selectedIdsChanged(); + Q_EMIT countChanged(); + Q_EMIT changed(); +} + +void IdSelectionModel::deselect(const QString& id) +{ + if (!m_selected.remove(id)) { + return; + } + if (m_currentId == id) { + setCurrentIdInternal(QString()); + } + Q_EMIT selectedIdsChanged(); + Q_EMIT countChanged(); + Q_EMIT changed(); +} + +void IdSelectionModel::toggle(const QString& id) +{ + if (m_selected.contains(id)) { + deselect(id); + } else { + select(id); + } +} + +void IdSelectionModel::clear() +{ + if (m_selected.isEmpty() && m_currentId.isEmpty()) { + return; + } + m_selected.clear(); + setCurrentIdInternal(QString()); + Q_EMIT selectedIdsChanged(); + Q_EMIT countChanged(); + Q_EMIT changed(); +} + +void IdSelectionModel::selectOnly(const QString& id) +{ + if (id.isEmpty()) { + clear(); + return; + } + if (m_selected.size() == 1 && m_selected.contains(id) && + m_currentId == id) { + return; + } + m_selected.clear(); + m_selected.insert(id); + setCurrentIdInternal(id); + Q_EMIT selectedIdsChanged(); + Q_EMIT countChanged(); + Q_EMIT changed(); +} + +bool IdSelectionModel::isSelected(const QString& id) const +{ + return m_selected.contains(id); +} diff --git a/launcher/models/IdSelectionModel.h b/launcher/models/IdSelectionModel.h new file mode 100644 index 00000000..9961b416 --- /dev/null +++ b/launcher/models/IdSelectionModel.h @@ -0,0 +1,86 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include + +/* + * Selection for the QML instance grid, keyed by instance id string rather + * than by QModelIndex/QPersistentModelIndex. + * + * QML has no QItemSelectionModel to begin with, but even a hand-rolled + * index-based equivalent would be the wrong tool here: the grid's model is + * InstanceFilterModel, a QSortFilterProxyModel, and its row numbers move + * every time the filter text, the group filter or the sort mode changes. A + * plain QModelIndex would silently point at a different instance after a + * reorder; a QPersistentModelIndex survives a reorder but is invalidated + * outright whenever the row it names drops out of the filter -- which is + * exactly what happens while the user is typing into the search box that + * owns filterText. An instance id has neither problem: it names the + * instance itself, not a position in some particular view of it, so it + * keeps meaning the same thing across every reorder and refilter. + */ +class IdSelectionModel : public QObject +{ + Q_OBJECT + + Q_PROPERTY(int count READ count NOTIFY countChanged) + Q_PROPERTY( + QStringList selectedIds READ selectedIds NOTIFY selectedIdsChanged) + Q_PROPERTY(QString currentId READ currentId NOTIFY currentIdChanged) + + public: + explicit IdSelectionModel(QObject* parent = nullptr); + + int count() const; + /// Unordered -- backed by a QSet, not insertion order. + QStringList selectedIds() const; + /// The most recently selected id, or empty when nothing is selected. + QString currentId() const; + + Q_INVOKABLE void select(const QString& id); + Q_INVOKABLE void deselect(const QString& id); + Q_INVOKABLE void toggle(const QString& id); + Q_INVOKABLE void clear(); + /// Replaces the whole selection with just @p id. An empty id clears the + /// selection instead, matching clear() rather than selecting "nothing". + Q_INVOKABLE void selectOnly(const QString& id); + Q_INVOKABLE bool isSelected(const QString& id) const; + + signals: + /// Fires alongside whichever of the signals below also fire, so a + /// binding that only cares "did anything change" does not need to listen + /// to all three. + void changed(); + void countChanged(); + void selectedIdsChanged(); + void currentIdChanged(); + + private: + /// Returns whether @p id actually replaced the previous current id, and + /// emits currentIdChanged() when it did. + bool setCurrentIdInternal(const QString& id); + + QSet m_selected; + QString m_currentId; +}; diff --git a/launcher/models/IdSelectionModel_test.cpp b/launcher/models/IdSelectionModel_test.cpp new file mode 100644 index 00000000..9323789e --- /dev/null +++ b/launcher/models/IdSelectionModel_test.cpp @@ -0,0 +1,207 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include + +#include "models/IdSelectionModel.h" + +class IdSelectionModelTest : public QObject +{ + Q_OBJECT + + private slots: + void test_select_addsIdAndBecomesCurrent() + { + IdSelectionModel selection; + QSignalSpy changed(&selection, &IdSelectionModel::changed); + QSignalSpy countChanged(&selection, &IdSelectionModel::countChanged); + QSignalSpy idsChanged(&selection, &IdSelectionModel::selectedIdsChanged); + QSignalSpy currentChanged(&selection, &IdSelectionModel::currentIdChanged); + + selection.select("a"); + + QVERIFY(selection.isSelected("a")); + QCOMPARE(selection.count(), 1); + QCOMPARE(selection.selectedIds(), QStringList{ "a" }); + QCOMPARE(selection.currentId(), QString("a")); + QCOMPARE(changed.count(), 1); + QCOMPARE(countChanged.count(), 1); + QCOMPARE(idsChanged.count(), 1); + QCOMPARE(currentChanged.count(), 1); + } + + void test_select_alreadySelected_emitsNothing() + { + IdSelectionModel selection; + selection.select("a"); + + QSignalSpy changed(&selection, &IdSelectionModel::changed); + selection.select("a"); + + QCOMPARE(changed.count(), 0); + QCOMPARE(selection.count(), 1); + } + + void test_select_emptyId_isIgnored() + { + IdSelectionModel selection; + QSignalSpy changed(&selection, &IdSelectionModel::changed); + + selection.select(QString()); + + QCOMPARE(changed.count(), 0); + QCOMPARE(selection.count(), 0); + } + + void test_deselect_removesId() + { + IdSelectionModel selection; + selection.select("a"); + selection.select("b"); + + QSignalSpy changed(&selection, &IdSelectionModel::changed); + selection.deselect("a"); + + QVERIFY(!selection.isSelected("a")); + QVERIFY(selection.isSelected("b")); + QCOMPARE(selection.count(), 1); + QCOMPARE(changed.count(), 1); + } + + void test_deselect_notSelected_emitsNothing() + { + IdSelectionModel selection; + selection.select("a"); + + QSignalSpy changed(&selection, &IdSelectionModel::changed); + selection.deselect("z"); + + QCOMPARE(changed.count(), 0); + } + + /// Deselecting the current id clears currentId rather than leaving it + /// pointing at an id that is no longer selected. + void test_deselect_currentId_clearsCurrentId() + { + IdSelectionModel selection; + selection.select("a"); + selection.select("b"); + QCOMPARE(selection.currentId(), QString("b")); + + selection.deselect("b"); + + QCOMPARE(selection.currentId(), QString()); + } + + /// Deselecting an id that is not the current one leaves currentId alone. + void test_deselect_otherId_leavesCurrentIdAlone() + { + IdSelectionModel selection; + selection.select("a"); + selection.select("b"); + + selection.deselect("a"); + + QCOMPARE(selection.currentId(), QString("b")); + } + + void test_toggle_flipsSelection() + { + IdSelectionModel selection; + selection.toggle("a"); + QVERIFY(selection.isSelected("a")); + + selection.toggle("a"); + QVERIFY(!selection.isSelected("a")); + QCOMPARE(selection.count(), 0); + } + + void test_clear_removesEverything() + { + IdSelectionModel selection; + selection.select("a"); + selection.select("b"); + + QSignalSpy changed(&selection, &IdSelectionModel::changed); + selection.clear(); + + QCOMPARE(selection.count(), 0); + QCOMPARE(selection.currentId(), QString()); + QCOMPARE(changed.count(), 1); + } + + void test_clear_alreadyEmpty_emitsNothing() + { + IdSelectionModel selection; + + QSignalSpy changed(&selection, &IdSelectionModel::changed); + selection.clear(); + + QCOMPARE(changed.count(), 0); + } + + void test_selectOnly_replacesWholeSelection() + { + IdSelectionModel selection; + selection.select("a"); + selection.select("b"); + + selection.selectOnly("c"); + + QCOMPARE(selection.count(), 1); + QVERIFY(selection.isSelected("c")); + QVERIFY(!selection.isSelected("a")); + QVERIFY(!selection.isSelected("b")); + QCOMPARE(selection.currentId(), QString("c")); + } + + void test_selectOnly_sameSingleSelection_emitsNothing() + { + IdSelectionModel selection; + selection.selectOnly("a"); + + QSignalSpy changed(&selection, &IdSelectionModel::changed); + selection.selectOnly("a"); + + QCOMPARE(changed.count(), 0); + } + + /// An empty id clears the selection instead of trying to select nothing. + void test_selectOnly_emptyId_clearsSelection() + { + IdSelectionModel selection; + selection.select("a"); + + selection.selectOnly(QString()); + + QCOMPARE(selection.count(), 0); + QCOMPARE(selection.currentId(), QString()); + } + + void test_isSelected_falseForUnknownId() + { + IdSelectionModel selection; + QVERIFY(!selection.isSelected("nope")); + } +}; + +QTEST_GUILESS_MAIN(IdSelectionModelTest) + +#include "IdSelectionModel_test.moc" diff --git a/launcher/models/InstanceDetails.cpp b/launcher/models/InstanceDetails.cpp new file mode 100644 index 00000000..48f1f319 --- /dev/null +++ b/launcher/models/InstanceDetails.cpp @@ -0,0 +1,661 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "InstanceDetails.h" + +#include +#include +#include +#include +#include +#include + +#include "core/LauncherContext.h" +#include "launch/LaunchTask.h" +#include "launch/LogModel.h" +#include "meta/Index.h" +#include "meta/VersionList.h" +#include "minecraft/MinecraftInstance.h" +#include "minecraft/PackProfile.h" +#include "minecraft/WorldList.h" +#include "minecraft/gameoptions/GameOptions.h" +#include "minecraft/mod/ModFolderModel.h" +#include "models/BackupController.h" +#include "models/ContentBrowser.h" +#include "models/KeyValueFilterModel.h" +#include "models/LoaderInstaller.h" +#include "models/ManagedPackController.h" +#include "models/NewInstanceController.h" +#include "models/OtherLogsModel.h" +#include "models/ServersListModel.h" +#include "models/SettingsAdapter.h" +#include "models/WorldDataPacksController.h" +#include "screenshots/ScreenshotListModel.h" +#include "tasks/Task.h" +#include "tasks/TaskWatcher.h" +#include "FileSystem.h" + +namespace +{ + /* Wraps @p source in the same by-name sorted proxy the mods list has + * always used: the folder model lists files in whatever order the + * directory gives them, but people look for content by name. */ + std::unique_ptr sortedByName(ModFolderModel* source) + { + auto proxy = std::make_unique(); + proxy->setSourceModel(source); + proxy->setSortRole(source->roleNames().key("name", Qt::DisplayRole)); + proxy->setSortCaseSensitivity(Qt::CaseInsensitive); + proxy->setDynamicSortFilter(true); + proxy->sort(0); + return proxy; + } + + /* Mirrors ModFolderPage::openedImpl(): startWatching() runs an + * update() itself the first time it actually starts watching, but a + * subsequent call (this bridge replacing a previous one, or a widget + * page already showing the same folder) only re-arms the + * QFileSystemWatcher. Force an update so @p model never hands QML + * stale data. */ + void startWatchingFresh(ModFolderModel* model) + { + const bool wasValid = model->isValid() && model->dir().exists(); + model->startWatching(); + if (wasValid) { + model->update(); + } + } +} // namespace + +/* Runs one World::install() copy off the GUI thread, the same + * QtConcurrent::run()+QFutureWatcher shape BackupController's BackupJobTask + * uses for its own long-running, plain succeeded/failed operations: a + * world's region files can run into gigabytes, and copyWorld() must not + * block the GUI thread doing that copy synchronously. Kept local rather + * than shared with BackupController.cpp for the same reason BackupJobTask + * itself is local there - nothing here needs progress reporting. */ +class WorldCopyTask : public Task +{ + Q_OBJECT + public: + using Fn = std::function; + + WorldCopyTask(QString status, Fn fn, QObject* parent = nullptr) + : Task(parent), m_fn(std::move(fn)) + { + setObjectName(QStringLiteral("WorldCopyTask")); + setStatus(status); + setProgress(0, 0); + } + + ~WorldCopyTask() override + { + disconnect(&m_watcher, nullptr, this, nullptr); + if (m_future.isRunning()) { + m_future.waitForFinished(); + } + } + + protected: + void executeTask() override + { + connect(&m_watcher, &QFutureWatcher::finished, this, [this] { + if (m_future.result()) { + setProgress(1, 1); + emitSucceeded(); + } else { + emitFailed(tr("The operation failed. See the launcher log " + "for details.")); + } + }); + m_future = QtConcurrent::run(QThreadPool::globalInstance(), m_fn); + m_watcher.setFuture(m_future); + } + + private: + Fn m_fn; + QFuture m_future; + QFutureWatcher m_watcher; +}; + +InstanceDetails::InstanceDetails(InstancePtr instance, QObject* parent) + : QObject(parent), m_instance(std::move(instance)) +{ + if (!m_instance) { + return; + } + + // Coarse, like InstanceList::propertiesChanged: name is not the only + // thing propertiesChanged can mean, but re-reading it on every one of + // these is cheap and never misses a rename. + connect(m_instance.get(), &BaseInstance::propertiesChanged, this, + [this](BaseInstance*) { emit nameChanged(); }); + connect(m_instance.get(), &BaseInstance::runningStatusChanged, this, + &InstanceDetails::onRunningStatusChanged); + + m_settings = std::make_unique(m_instance->settings()); + + m_mc = dynamic_cast(m_instance.get()); + if (m_mc) { + m_mods = m_mc->loaderModList(); + m_sortedMods = sortedByName(m_mods.get()); + startWatchingFresh(m_mods.get()); + + m_resourcePacks = m_mc->resourcePackList(); + m_sortedResourcePacks = sortedByName(m_resourcePacks.get()); + startWatchingFresh(m_resourcePacks.get()); + + m_shaderPacks = m_mc->shaderPackList(); + m_sortedShaderPacks = sortedByName(m_shaderPacks.get()); + startWatchingFresh(m_shaderPacks.get()); + + // Legacy-only: see the texturePacks Q_PROPERTY comment. + if (m_mc->traits().contains("texturepacks")) { + m_texturePacks = m_mc->texturePackList(); + m_sortedTexturePacks = sortedByName(m_texturePacks.get()); + startWatchingFresh(m_texturePacks.get()); + } + + m_worlds = m_mc->worldList(); + // Mirrors WorldListPage::openedImpl(). + m_worlds->startWatching(); + + // Borrowed from the instance, like m_mods/m_worlds above - see the + // gameOptions Q_PROPERTY comment. + m_gameOptions = m_mc->gameOptionsModel(); + m_gameOptionsFilter = new KeyValueFilterModel(this); + m_gameOptionsFilter->setSourceModel(m_gameOptions.get()); + + // Mirrors ServersPage's own ServersModel lifetime: watched for as + // long as the page (here, this bridge) is open, locked exactly + // while the instance is running. + m_servers = std::make_unique(m_instance->gameRoot()); + m_servers->setLocked(m_instance->isRunning()); + m_servers->startWatching(); + } + + // Every instance can be backed up, Minecraft-backed or not. + m_backups = new BackupController(m_instance, this); + + /* An instance that never took a screenshot has no folder yet; the + * model then simply lists nothing until the page is opened again. */ + m_screenshots = std::make_unique(); + m_screenshots->setDirectory(screenshotsDir()); + + m_log = new InstanceLogBridge(m_instance, this); + + /* Unlike contentBrowser()/loaderInstaller(), this touches only the + * filesystem (a QFileSystemWatcher on the instance's log root), not + * the network, so there is no reason to delay creating it - mirrors + * m_mods/m_worlds above. Pure-virtual on BaseInstance, but not every + * instance type necessarily has a filter to offer. */ + if (auto matcher = m_instance->getLogFileMatcher()) { + m_otherLogs = + new OtherLogsModel(m_instance->getLogFileRoot(), matcher, this); + } +} + +InstanceDetails::~InstanceDetails() +{ + // Mirrors ModFolderPage::~ModFolderPage() / WorldListPage::~WorldListPage(): + // the models are owned by the instance, not by this bridge, but nothing + // else stops watching them once this detail page closes. + if (m_mods) { + m_mods->stopWatching(); + } + if (m_resourcePacks) { + m_resourcePacks->stopWatching(); + } + if (m_shaderPacks) { + m_shaderPacks->stopWatching(); + } + if (m_texturePacks) { + m_texturePacks->stopWatching(); + } + if (m_worlds) { + m_worlds->stopWatching(); + } + if (m_servers) { + m_servers->stopWatching(); + } +} + +QString InstanceDetails::instanceId() const +{ + return m_instance ? m_instance->id() : QString(); +} + +QString InstanceDetails::name() const +{ + return m_instance ? m_instance->name() : QString(); +} + +QString InstanceDetails::gameRoot() const +{ + return m_instance ? m_instance->gameRoot() : QString(); +} + +QString InstanceDetails::instanceRoot() const +{ + return m_instance ? m_instance->instanceRoot() : QString(); +} + +QObject* InstanceDetails::settings() const +{ + return m_settings.get(); +} + +QString InstanceDetails::notes() const +{ + return m_instance ? m_instance->notes() : QString(); +} + +void InstanceDetails::setNotes(const QString& notes) +{ + // BaseInstance::setNotes() does not itself emit anything (unlike most + // of its other setters) - notify explicitly, and only on a real change. + if (!m_instance || m_instance->notes() == notes) { + return; + } + m_instance->setNotes(notes); + emit notesChanged(); +} + +QObject* InstanceDetails::mods() const +{ + return m_sortedMods.get(); +} + +QString InstanceDetails::modsDir() const +{ + return m_mods ? m_mods->dir().absolutePath() : QString(); +} + +void InstanceDetails::setModEnabled(int row, bool enabled) +{ + setEnabled(QStringLiteral("mods"), row, enabled); +} + +void InstanceDetails::deleteMod(int row) +{ + remove(QStringLiteral("mods"), row); +} + +bool InstanceDetails::installMod(const QString& fileUrlOrPath) +{ + return install(QStringLiteral("mods"), fileUrlOrPath); +} + +QObject* InstanceDetails::resourcePacks() const +{ + return m_sortedResourcePacks.get(); +} + +QString InstanceDetails::resourcePacksDir() const +{ + return m_resourcePacks ? m_resourcePacks->dir().absolutePath() : QString(); +} + +QObject* InstanceDetails::shaderPacks() const +{ + return m_sortedShaderPacks.get(); +} + +QString InstanceDetails::shaderPacksDir() const +{ + return m_shaderPacks ? m_shaderPacks->dir().absolutePath() : QString(); +} + +QObject* InstanceDetails::texturePacks() const +{ + return m_sortedTexturePacks.get(); +} + +QString InstanceDetails::texturePacksDir() const +{ + return m_texturePacks ? m_texturePacks->dir().absolutePath() : QString(); +} + +ModFolderModel* InstanceDetails::folderModel(const QString& kind) const +{ + if (kind == QStringLiteral("mods")) { + return m_mods.get(); + } + if (kind == QStringLiteral("resourcepacks")) { + return m_resourcePacks.get(); + } + if (kind == QStringLiteral("shaderpacks")) { + return m_shaderPacks.get(); + } + if (kind == QStringLiteral("texturepacks")) { + return m_texturePacks.get(); + } + return nullptr; +} + +QSortFilterProxyModel* InstanceDetails::sortedFolderModel(const QString& kind) const +{ + if (kind == QStringLiteral("mods")) { + return m_sortedMods.get(); + } + if (kind == QStringLiteral("resourcepacks")) { + return m_sortedResourcePacks.get(); + } + if (kind == QStringLiteral("shaderpacks")) { + return m_sortedShaderPacks.get(); + } + if (kind == QStringLiteral("texturepacks")) { + return m_sortedTexturePacks.get(); + } + return nullptr; +} + +QModelIndex InstanceDetails::sourceIndexFor(const QString& kind, int row) const +{ + auto* sorted = sortedFolderModel(kind); + if (!sorted || row < 0 || row >= sorted->rowCount()) { + return {}; + } + return sorted->mapToSource(sorted->index(row, 0)); +} + +void InstanceDetails::setEnabled(const QString& kind, int row, bool enabled) +{ + auto* model = folderModel(kind); + const QModelIndex index = sourceIndexFor(kind, row); + if (!model || !index.isValid()) { + return; + } + model->setModStatus({ index }, + enabled ? ModFolderModel::Enable + : ModFolderModel::Disable); +} + +void InstanceDetails::remove(const QString& kind, int row) +{ + auto* model = folderModel(kind); + const QModelIndex index = sourceIndexFor(kind, row); + if (!model || !index.isValid()) { + return; + } + model->deleteMods({ index }); +} + +bool InstanceDetails::install(const QString& kind, const QString& fileUrlOrPath) +{ + auto* model = folderModel(kind); + if (!model) { + return false; + } + // Same conversion ModFolderModel's own drop handling uses + // (dropMimeData(): url.toLocalFile()) - a QML FileDialog hands out + // file:// URLs, but accept a plain path too. + const QUrl url(fileUrlOrPath); + const QString path = url.isLocalFile() ? url.toLocalFile() : fileUrlOrPath; + return model->installMod(path); +} + +bool InstanceDetails::contentChangesAllowed() const +{ + // Mirrors ModFolderPage::contentChangesAllowed() specialized to mods: + // allowsChangesWhileRunning() is always false for ModPlatform::ContentType::Mod, + // so that page's rule reduces to exactly this. + return m_instance && !m_instance->isRunning(); +} + +QObject* InstanceDetails::contentBrowser() const +{ + // Lazy on purpose: instantiated the first time anything reads this + // property, not in the constructor above - opening this page must not + // start the network activity a search or an install would. Null for a + // non-Minecraft instance, matching mods()/components() etc. above. + if (!m_contentBrowser && m_mc) { + m_contentBrowser = + std::make_unique(m_mc, const_cast(this)); + } + return m_contentBrowser.get(); +} + +QObject* InstanceDetails::worlds() const +{ + return m_worlds.get(); +} + +QString InstanceDetails::worldsDir() const +{ + return m_worlds ? m_worlds->dir().absolutePath() : QString(); +} + +QObject* InstanceDetails::screenshots() const +{ + return m_screenshots.get(); +} + +QString InstanceDetails::screenshotsDir() const +{ + return FS::PathCombine(m_instance->gameRoot(), "screenshots"); +} + +void InstanceDetails::deleteWorld(int row) +{ + if (!m_worlds || row < 0 || static_cast(row) >= m_worlds->size()) { + return; + } + m_worlds->deleteWorld(row); +} + +bool InstanceDetails::renameWorld(int row, const QString& name) +{ + // Same trim rule sanitizedInstanceName() applies - a world name with + // only whitespace is not a usable name. + const QString trimmed = name.trimmed(); + if (!m_worlds || trimmed.isEmpty() || row < 0 || + static_cast(row) >= m_worlds->size()) { + return false; + } + return (*m_worlds)[static_cast(row)].rename(trimmed); +} + +QObject* InstanceDetails::copyWorld(int row, const QString& name) +{ + const QString trimmed = name.trimmed(); + if (!m_worlds || trimmed.isEmpty() || row < 0 || + static_cast(row) >= m_worlds->size()) { + return nullptr; + } + + // World is a plain value type (no pointers, no QObject) - safe to copy + // and install() from a worker thread the same way BackupController + // captures its BackupManager by value. + World world = (*m_worlds)[static_cast(row)]; + const QString to = m_worlds->dir().absolutePath(); + + auto* task = new WorldCopyTask( + tr("Copying world…"), + [world, to, trimmed]() mutable { return world.install(to, trimmed); }); + + auto* watcher = new TaskWatcher(Task::Ptr(task), this); + watcher->setTitle(tr("Copy world")); + task->start(); + return watcher; +} + +bool InstanceDetails::resetWorldIcon(int row) +{ + if (!m_worlds) { + return false; + } + return m_worlds->resetIcon(row); +} + +QObject* InstanceDetails::worldDataPacks() const +{ + if (!m_worldDataPacks && m_mc) { + m_worldDataPacks = std::make_unique( + m_mc, m_worlds.get(), const_cast(this)); + } + return m_worldDataPacks.get(); +} + +QObject* InstanceDetails::servers() const +{ + return m_servers.get(); +} + +QString InstanceDetails::serversDir() const +{ + return m_instance ? m_instance->gameRoot() : QString(); +} + +QObject* InstanceDetails::backups() const +{ + return m_backups; +} + +QObject* InstanceDetails::managedPack() const +{ + if (!m_managedPackChecked) { + m_managedPackChecked = true; + if (m_instance && ManagedPackController::isSupported(m_instance.get())) { + m_managedPack = std::make_unique( + m_instance.get(), const_cast(this)); + } + } + return m_managedPack.get(); +} + +QObject* InstanceDetails::log() const +{ + return m_log; +} + +QObject* InstanceDetails::otherLogs() const +{ + return m_otherLogs; +} + +QObject* InstanceDetails::components() const +{ + return m_mc ? m_mc->getPackProfile().get() : nullptr; +} + +QObject* InstanceDetails::minecraftVersions() const +{ + // Lazy for the same reason as contentBrowser()/loaderInstaller() - + // nothing here downloads anything until QML actually reads this + // property (see VersionListLoadingProxy::startLoadIfNeeded(), run by + // setSourceModel() below). + if (!m_minecraftVersions && m_mc) { + m_minecraftVersions = std::make_unique( + const_cast(this)); + auto list = + LAUNCHER->metadataIndex()->get(QStringLiteral("net.minecraft")); + m_minecraftVersions->setSourceModel(list.get()); + } + return m_minecraftVersions.get(); +} + +QObject* InstanceDetails::loaderInstaller() const +{ + // Lazy for the same reason as contentBrowser() above: nothing here + // downloads anything until a loader is actually selected, but there is + // no reason to build the object at all for a non-Minecraft instance. + if (!m_loaderInstaller && m_mc) { + m_loaderInstaller = std::make_unique( + m_mc->getPackProfile().get(), const_cast(this)); + } + return m_loaderInstaller.get(); +} + +QObject* InstanceDetails::gameOptions() const +{ + return m_gameOptionsFilter; +} + +bool InstanceDetails::isMinecraft() const +{ + return m_mc != nullptr; +} + +void InstanceDetails::onRunningStatusChanged(bool running) +{ + emit contentChangesAllowedChanged(); + if (m_servers) { + m_servers->setLocked(running); + } +} + +// --------------------------------------------------------------------------- + +InstanceLogBridge::InstanceLogBridge(InstancePtr instance, QObject* parent) + : QObject(parent), m_instance(std::move(instance)) +{ + if (!m_instance) { + return; + } + + // Mirrors LogPage::LogPage(): pick up whatever launch is already in + // flight, then follow every launch after that. + m_task = m_instance->getLaunchTask(); + if (m_task) { + m_model = m_task->getLogModel(); + } + connect(m_instance.get(), &BaseInstance::launchTaskChanged, this, + &InstanceLogBridge::onLaunchTaskChanged); +} + +InstanceLogBridge::~InstanceLogBridge() = default; + +QObject* InstanceLogBridge::model() const +{ + return m_model.get(); +} + +bool InstanceLogBridge::hasLog() const +{ + return m_model != nullptr; +} + +void InstanceLogBridge::clear() +{ + if (m_model) { + m_model->clear(); + } +} + +QString InstanceLogBridge::text() +{ + return m_model ? m_model->toPlainText() : QString(); +} + +void InstanceLogBridge::setSuspended(bool suspended) +{ + if (m_model) { + m_model->suspend(suspended); + } +} + +void InstanceLogBridge::onLaunchTaskChanged(shared_qobject_ptr task) +{ + m_task = task; + m_model = m_task ? m_task->getLogModel() : shared_qobject_ptr(); + emit modelChanged(); +} + +#include "InstanceDetails.moc" diff --git a/launcher/models/InstanceDetails.h b/launcher/models/InstanceDetails.h new file mode 100644 index 00000000..a5816c01 --- /dev/null +++ b/launcher/models/InstanceDetails.h @@ -0,0 +1,368 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include + +#include "BaseInstance.h" +#include "QObjectPtr.h" + +class MinecraftInstance; +class ModFolderModel; +class QSortFilterProxyModel; +class WorldList; +class SettingsAdapter; +class ScreenshotListModel; +class InstanceLogBridge; +class LaunchTask; +class LogModel; +class ContentBrowser; +class OtherLogsModel; +class LoaderInstaller; +class GameOptions; +class KeyValueFilterModel; +class MinecraftVersionListProxy; +class ServersListModel; +class BackupController; +class WorldDataPacksController; +class ManagedPackController; + +/* + * QML-facing bridge for one instance's detail page: notes, mods, worlds, + * the live log, installed components (loader + Minecraft version) and + * per-instance settings overrides, all in one object so QmlShell only has + * to hand QML a single thing per open instance. + * + * Holds the InstancePtr itself (a shared_ptr): that is what keeps this + * bridge from outliving the instance in a way that crashes, since the + * instance simply cannot be destroyed while this bridge is still holding a + * reference to it. Everything this bridge exposes that it did not create + * itself (the mod/world/component models) is owned by the instance and + * only borrowed here - the destructor stops watching them, but never + * deletes them. + * + * Core, like the rest of models/: QtCore only, no QtWidgets, no ui/. + */ +class InstanceDetails : public QObject +{ + Q_OBJECT + + Q_PROPERTY(QString instanceId READ instanceId CONSTANT) + Q_PROPERTY(QString name READ name NOTIFY nameChanged) + Q_PROPERTY(QString gameRoot READ gameRoot CONSTANT) + Q_PROPERTY(QString instanceRoot READ instanceRoot CONSTANT) + /// SettingsAdapter over this instance's own SettingsObject. + Q_PROPERTY(QObject* settings READ settings CONSTANT) + Q_PROPERTY(QString notes READ notes WRITE setNotes NOTIFY notesChanged) + + /// The loader mods ModFolderModel (same one ModFolderPage's + /// loaderModList() tab shows), or null if this instance is not + /// Minecraft-backed. + Q_PROPERTY(QObject* mods READ mods CONSTANT) + Q_PROPERTY(QString modsDir READ modsDir CONSTANT) + /// Same shape as mods() above, over MinecraftInstance::resourcePackList() + /// / shaderPackList(). Null if this instance is not Minecraft-backed. + Q_PROPERTY(QObject* resourcePacks READ resourcePacks CONSTANT) + Q_PROPERTY(QString resourcePacksDir READ resourcePacksDir CONSTANT) + Q_PROPERTY(QObject* shaderPacks READ shaderPacks CONSTANT) + Q_PROPERTY(QString shaderPacksDir READ shaderPacksDir CONSTANT) + /// Null unless this instance predates resource packs and uses texture + /// packs instead (traits() has "texturepacks" - see TexturePackPage's + /// own shouldDisplay()); legacy Minecraft only. + Q_PROPERTY(QObject* texturePacks READ texturePacks CONSTANT) + Q_PROPERTY(QString texturePacksDir READ texturePacksDir CONSTANT) + /// False while the instance is running - same rule ModFolderPage + /// enforces for the Mods tab specifically (contentChangesAllowed()). + Q_PROPERTY(bool contentChangesAllowed READ contentChangesAllowed NOTIFY + contentChangesAllowedChanged) + + /// CurseForge/Modrinth search + install for this instance's mods (and + /// resource/shader/data packs), the QML-facing replacement for + /// DownloadContentDialog. Created lazily on first access, so opening + /// this page never touches the network by itself - see + /// ContentBrowser's own class comment. + Q_PROPERTY(QObject* contentBrowser READ contentBrowser CONSTANT) + + Q_PROPERTY(QObject* worlds READ worlds CONSTANT) + Q_PROPERTY(QString worldsDir READ worldsDir CONSTANT) + /// Per-world data packs (saves/<world>/datapacks) - the QML-facing + /// replacement for the dialog WorldListPage's "Data packs" action used + /// to open. Null if this instance is not Minecraft-backed. + Q_PROPERTY(QObject* worldDataPacks READ worldDataPacks CONSTANT) + + /// This instance's servers.dat - the QML-facing replacement for + /// ServersPage. Null if this instance is not Minecraft-backed. + Q_PROPERTY(QObject* servers READ servers CONSTANT) + /// Where servers.dat lives, for an "open folder" action. + Q_PROPERTY(QString serversDir READ serversDir CONSTANT) + + /// Backup snapshots of this instance - the QML-facing replacement for + /// BackupPage. Always available, regardless of instance type. + Q_PROPERTY(QObject* backups READ backups CONSTANT) + + /// This instance's Modrinth/CurseForge provenance - the QML-facing + /// replacement for ManagedPackPage. Null unless the instance actually + /// qualifies (see ManagedPackController::isSupported()). + Q_PROPERTY(QObject* managedPack READ managedPack CONSTANT) + + /// ScreenshotListModel over the game's screenshots folder. + Q_PROPERTY(QObject* screenshots READ screenshots CONSTANT) + Q_PROPERTY(QString screenshotsDir READ screenshotsDir CONSTANT) + + /// InstanceLogBridge for the current (or most recent) launch. + Q_PROPERTY(QObject* log READ log CONSTANT) + /// Every other log file this instance has (logs/*.log*, + /// crash-reports/*.txt) - the QML-facing replacement for + /// OtherLogsPage. Null if the instance names no log filter at all + /// (see BaseInstance::getLogFileMatcher()). + Q_PROPERTY(QObject* otherLogs READ otherLogs CONSTANT) + /// This instance's PackProfile, read-only version list, or null. + Q_PROPERTY(QObject* components READ components CONSTANT) + /// "net.minecraft"'s version list (MinecraftVersionListProxy - see + /// models/NewInstanceController.h, the same proxy the "New instance" + /// dialog's own Minecraft version picker uses), for VersionTab.qml's + /// "Change Minecraft version" action. Created lazily on first access; + /// null if this instance is not Minecraft-backed. + Q_PROPERTY(QObject* minecraftVersions READ minecraftVersions CONSTANT) + /// Installs a mod loader into this instance's components - the + /// QML-facing replacement for InstallLoaderDialog. Created lazily on + /// first access, same reason as contentBrowser() above. Null if this + /// instance is not Minecraft-backed. + Q_PROPERTY(QObject* loaderInstaller READ loaderInstaller CONSTANT) + /// This instance's options.txt as a flat key/value list (GameOptions' + /// own `key`/`value` roles, through a KeyValueFilterModel for + /// GameOptionsTab.qml's search box) - the QML-facing replacement for + /// GameOptionsPage, which is read-only for the same reason this is: + /// see that class's own header comment. Null if this instance is not + /// Minecraft-backed. + Q_PROPERTY(QObject* gameOptions READ gameOptions CONSTANT) + Q_PROPERTY(bool isMinecraft READ isMinecraft CONSTANT) + + public: + explicit InstanceDetails(InstancePtr instance, QObject* parent = nullptr); + ~InstanceDetails() override; + + QString instanceId() const; + QString name() const; + QString gameRoot() const; + QString instanceRoot() const; + QObject* settings() const; + + QString notes() const; + void setNotes(const QString& notes); + + QObject* mods() const; + QString modsDir() const; + Q_INVOKABLE void setModEnabled(int row, bool enabled); + Q_INVOKABLE void deleteMod(int row); + /// @p fileUrlOrPath: a file:// URL (as a QML FileDialog hands out) or + /// a plain local path. + Q_INVOKABLE bool installMod(const QString& fileUrlOrPath); + + QObject* resourcePacks() const; + QString resourcePacksDir() const; + QObject* shaderPacks() const; + QString shaderPacksDir() const; + QObject* texturePacks() const; + QString texturePacksDir() const; + + /// Generalised form of setModEnabled()/deleteMod()/installMod() above + /// (which now just forward to these), covering every folder-backed + /// content type this bridge exposes. @p kind is one of "mods", + /// "resourcepacks", "shaderpacks", "texturepacks"; a call with any + /// other @p kind, or one this instance does not have, is a no-op + /// (install() returns false). + Q_INVOKABLE void setEnabled(const QString& kind, int row, bool enabled); + Q_INVOKABLE void remove(const QString& kind, int row); + /// @p fileUrlOrPath: a file:// URL (as a QML FileDialog hands out) or + /// a plain local path. + Q_INVOKABLE bool install(const QString& kind, const QString& fileUrlOrPath); + + bool contentChangesAllowed() const; + QObject* contentBrowser() const; + + QObject* worlds() const; + QString worldsDir() const; + Q_INVOKABLE void deleteWorld(int row); + /// False (no change) for a name that trims to nothing, same rule + /// renameInstance() uses. + Q_INVOKABLE bool renameWorld(int row, const QString& name); + /// Installs a copy of world @p row under a new name in the same + /// worlds folder - the QML-facing replacement for WorldListPage's + /// "Copy" action. Copies off the GUI thread (a world can run into + /// gigabytes) behind a TaskWatcher, the same BackupController shape; + /// returns null immediately for an out-of-range row or a name that + /// trims to nothing. + Q_INVOKABLE QObject* copyWorld(int row, const QString& name); + Q_INVOKABLE bool resetWorldIcon(int row); + QObject* worldDataPacks() const; + + QObject* servers() const; + QString serversDir() const; + + QObject* backups() const; + + QObject* managedPack() const; + + QObject* screenshots() const; + QString screenshotsDir() const; + + QObject* log() const; + QObject* otherLogs() const; + QObject* components() const; + QObject* minecraftVersions() const; + QObject* loaderInstaller() const; + QObject* gameOptions() const; + bool isMinecraft() const; + + signals: + void nameChanged(); + void notesChanged(); + void contentChangesAllowedChanged(); + + private slots: + void onRunningStatusChanged(bool running); + + private: + /// The source ModFolderModel behind @p kind ("mods", "resourcepacks", + /// "shaderpacks", "texturepacks"), or null if @p kind is unrecognised + /// or this instance does not have one (e.g. "texturepacks" on a + /// non-legacy instance, or any kind on a non-Minecraft instance). + ModFolderModel* folderModel(const QString& kind) const; + /// The sorted proxy QML sees for @p kind, or null - see folderModel(). + QSortFilterProxyModel* sortedFolderModel(const QString& kind) const; + /// Row @p row of the sorted proxy for @p kind, as an index into its + /// source model; invalid if @p kind or @p row is out of range. + QModelIndex sourceIndexFor(const QString& kind, int row) const; + + InstancePtr m_instance; + /// Non-owning; valid for as long as m_instance is (which is for the + /// lifetime of this object). Null when the instance is not Minecraft. + MinecraftInstance* m_mc = nullptr; + + /// Borrowed from the instance, not created here - see the class + /// comment. + std::shared_ptr m_mods; + /// What QML sees of m_mods: sorted by name, rows mapped back on write. + std::unique_ptr m_sortedMods; + /// Same borrowed/sorted-proxy shape as m_mods/m_sortedMods above. + std::shared_ptr m_resourcePacks; + std::unique_ptr m_sortedResourcePacks; + std::shared_ptr m_shaderPacks; + std::unique_ptr m_sortedShaderPacks; + /// Null unless the instance's traits() has "texturepacks" - see the + /// texturePacks Q_PROPERTY comment. + std::shared_ptr m_texturePacks; + std::unique_ptr m_sortedTexturePacks; + std::shared_ptr m_worlds; + /// Created on first worldDataPacks() call, not here - mirrors + /// m_contentBrowser below. + mutable std::unique_ptr m_worldDataPacks; + /// Created on first contentBrowser() call, not here - see that + /// method and the Q_PROPERTY comment above. + mutable std::unique_ptr m_contentBrowser; + + std::unique_ptr m_settings; + std::unique_ptr m_screenshots; + /// Owned via QObject parentage (parent is `this`). + InstanceLogBridge* m_log = nullptr; + /// Owned via QObject parentage (parent is `this`); null if the + /// instance names no log filter at all - see the otherLogs + /// Q_PROPERTY comment. + OtherLogsModel* m_otherLogs = nullptr; + /// Created on first minecraftVersions() call, not here - mirrors + /// m_contentBrowser above. Parented to `this` in that getter (like + /// LoaderInstaller's own m_versions - see its constructor comment), + /// so it needs no separate expose() call from QmlShell either. + mutable std::unique_ptr m_minecraftVersions; + /// Created on first loaderInstaller() call, not here - mirrors + /// m_contentBrowser above. + mutable std::unique_ptr m_loaderInstaller; + /// Borrowed from the instance, not created here - see the class + /// comment. Null when the instance is not Minecraft. + std::shared_ptr m_gameOptions; + /// Wraps m_gameOptions for gameOptions() above; created together with + /// it (both null, or both set, for a non-Minecraft instance) rather + /// than lazily, since building it touches no disk or network beyond + /// what m_gameOptions itself already did. Parented to `this` - same + /// reasoning as LoaderInstaller::m_versions. + KeyValueFilterModel* m_gameOptionsFilter = nullptr; + + /// Null when the instance is not Minecraft - see the servers + /// Q_PROPERTY comment. + std::unique_ptr m_servers; + /// Always created - see the backups Q_PROPERTY comment. Owned via + /// QObject parentage (parent is `this`). + BackupController* m_backups = nullptr; + /// Created on first managedPack() call, not here - mirrors + /// m_contentBrowser above: isSupported() is cheap, but there is no + /// reason to build the object at all for the overwhelming majority of + /// instances that are not managed packs. + mutable std::unique_ptr m_managedPack; + /// Set once managedPack() has run isSupported() the first time, so a + /// later call does not keep re-checking it (the answer cannot change + /// during this bridge's lifetime - it depends only on instance.cfg + /// fields and the CurseForge API key). + mutable bool m_managedPackChecked = false; +}; + +/* + * QML-facing view of "the current LaunchTask's LogModel", if any. + * + * Follows LogPage::setInstanceLaunchTaskChanged: a LaunchTask (and its + * LogModel) only exists while a launch is in flight, so model() is null + * between launches and re-points at a fresh LogModel every time + * BaseInstance starts a new one. + */ +class InstanceLogBridge : public QObject +{ + Q_OBJECT + + /// The current LaunchTask's LogModel, or null when the instance is + /// not currently launching/running. + Q_PROPERTY(QObject* model READ model NOTIFY modelChanged) + Q_PROPERTY(bool hasLog READ hasLog NOTIFY modelChanged) + + public: + explicit InstanceLogBridge(InstancePtr instance, QObject* parent = nullptr); + ~InstanceLogBridge() override; + + QObject* model() const; + bool hasLog() const; + + Q_INVOKABLE void clear(); + /// The whole log as plain text, e.g. for a QML copy action. + Q_INVOKABLE QString text(); + Q_INVOKABLE void setSuspended(bool suspended); + + signals: + void modelChanged(); + + private slots: + void onLaunchTaskChanged(shared_qobject_ptr task); + + private: + InstancePtr m_instance; + shared_qobject_ptr m_task; + shared_qobject_ptr m_model; +}; diff --git a/launcher/models/InstanceDetails_test.cpp b/launcher/models/InstanceDetails_test.cpp new file mode 100644 index 00000000..6e98829f --- /dev/null +++ b/launcher/models/InstanceDetails_test.cpp @@ -0,0 +1,168 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include +#include + +#include "models/InstanceDetails.h" + +#include "NullInstance.h" +#include "launch/LaunchTask.h" +#include "launch/LogModel.h" +#include "settings/INISettingsObject.h" + +/* Only InstanceLogBridge is exercised here - constructing a full + * MinecraftInstance (needed for InstanceDetails' mods/worlds/components) + * is impractical in a unit test. InstanceLogBridge only needs a + * BaseInstance and a LaunchTask, both of which are usable directly: + * NullInstance is a ready-made minimal concrete BaseInstance, and + * LaunchTask itself (not an OS-specific subclass) is what + * LaunchTask::create() hands back. */ +namespace +{ + /* BaseInstance's constructor registers overrides/passthroughs against + * these global setting ids; every one of them has to already be + * registered on whatever SettingsObject is passed in as + * globalSettings, or the registration calls hand it a null Setting. */ + SettingsObjectPtr makeGlobalSettings(QTemporaryDir& dir) + { + auto settings = std::make_shared( + dir.filePath("global.ini")); + settings->registerSetting("PreLaunchCommand", ""); + settings->registerSetting("WrapperCommand", ""); + settings->registerSetting("PostExitCommand", ""); + settings->registerSetting("ShowConsole", true); + settings->registerSetting("AutoCloseConsole", false); + settings->registerSetting("ShowConsoleOnError", true); + settings->registerSetting("LogPrePostOutput", true); + settings->registerSetting("ConsoleMaxLines", 100000); + settings->registerSetting("ConsoleOverflowStop", true); + return settings; + } + + /* Every Setting a SettingsObject registers keeps a raw SettingsObject* + * back-pointer to it (Setting::m_storage), including ones an instance + * only holds a passthrough/override *over* (PassthroughSetting::m_other + * etc.) - so the global SettingsObject has to stay alive for as long as + * the instance does, not just for the call that constructs it. Bundling + * them in one fixture, in this declaration order, makes that automatic. */ + struct InstanceFixture { + QTemporaryDir globalDir; + QTemporaryDir instDir; + SettingsObjectPtr global = makeGlobalSettings(globalDir); + InstancePtr instance = std::make_shared( + global, + std::make_shared( + instDir.filePath("instance.cfg")), + instDir.path()); + }; +} // namespace + +class InstanceLogBridgeTest : public QObject +{ + Q_OBJECT + + private slots: + void test_NoLaunchTask_ModelIsNullAndHasLogIsFalse() + { + InstanceFixture fixture; + InstanceLogBridge bridge(fixture.instance); + + QVERIFY(bridge.model() == nullptr); + QVERIFY(!bridge.hasLog()); + QCOMPARE(bridge.text(), QString()); + } + + void test_LaunchTaskChanged_SwitchesToItsLogModel() + { + InstanceFixture fixture; + InstanceLogBridge bridge(fixture.instance); + QSignalSpy modelSpy(&bridge, &InstanceLogBridge::modelChanged); + + auto task = LaunchTask::create(fixture.instance); + emit fixture.instance->launchTaskChanged(task); + + QCOMPARE(modelSpy.count(), 1); + QVERIFY(bridge.model() != nullptr); + QVERIFY(bridge.hasLog()); + QCOMPARE(bridge.model(), task->getLogModel().get()); + } + + void test_ClearAndTextPassThroughToTheLogModel() + { + InstanceFixture fixture; + InstanceLogBridge bridge(fixture.instance); + auto task = LaunchTask::create(fixture.instance); + emit fixture.instance->launchTaskChanged(task); + + task->getLogModel()->append(MessageLevel::Info, "hello"); + QCOMPARE(bridge.text(), QString("hello\n")); + + bridge.clear(); + QCOMPARE(bridge.text(), QString()); + } + + void test_SetSuspended_StopsTheModelAcceptingLines() + { + InstanceFixture fixture; + InstanceLogBridge bridge(fixture.instance); + auto task = LaunchTask::create(fixture.instance); + emit fixture.instance->launchTaskChanged(task); + + bridge.setSuspended(true); + task->getLogModel()->append(MessageLevel::Info, "should be dropped"); + + QCOMPARE(bridge.text(), QString()); + } + + void test_LaunchTaskReplaced_SwitchesAgain() + { + InstanceFixture fixture; + InstanceLogBridge bridge(fixture.instance); + + auto task1 = LaunchTask::create(fixture.instance); + emit fixture.instance->launchTaskChanged(task1); + auto* model1 = bridge.model(); + + auto task2 = LaunchTask::create(fixture.instance); + emit fixture.instance->launchTaskChanged(task2); + + QVERIFY(bridge.model() != model1); + QCOMPARE(bridge.model(), task2->getLogModel().get()); + } + + void test_LaunchTaskClearedToNull_ModelGoesBackToNull() + { + InstanceFixture fixture; + InstanceLogBridge bridge(fixture.instance); + auto task = LaunchTask::create(fixture.instance); + emit fixture.instance->launchTaskChanged(task); + QVERIFY(bridge.hasLog()); + + emit fixture.instance->launchTaskChanged(shared_qobject_ptr()); + + QVERIFY(bridge.model() == nullptr); + QVERIFY(!bridge.hasLog()); + } +}; + +QTEST_GUILESS_MAIN(InstanceLogBridgeTest) + +#include "InstanceDetails_test.moc" diff --git a/launcher/models/InstanceFilterModel.cpp b/launcher/models/InstanceFilterModel.cpp new file mode 100644 index 00000000..a1db3a7d --- /dev/null +++ b/launcher/models/InstanceFilterModel.cpp @@ -0,0 +1,345 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "InstanceFilterModel.h" + +#include + +#include "core/LauncherContext.h" +#include "settings/SettingsObject.h" + +namespace +{ + /* First role in @p model's roleNames() named @p name, or @p fallback if + * @p model is null or names no role that way. */ + int roleByName(const QAbstractItemModel* model, const QByteArray& name, + int fallback) + { + if (!model) { + return fallback; + } + const auto roles = model->roleNames(); + for (auto it = roles.constBegin(); it != roles.constEnd(); ++it) { + if (it.value() == name) { + return it.key(); + } + } + return fallback; + } + + /* Natural order that does not depend on QCollator::setNumericMode(), + * which some backends silently ignore (Qt without ICU on Linux sorted + * "Pack 10" before "Pack 2"). Runs of digits compare by value -- first + * by length once leading zeros are dropped, then digit by digit, so any + * length works -- and everything between them goes through @p collator. + */ + int naturalCompare(const QCollator& collator, const QString& a, + const QString& b) + { + int i = 0; + int j = 0; + while (i < a.size() && j < b.size()) { + const bool digitA = a.at(i).isDigit(); + const bool digitB = b.at(j).isDigit(); + int endA = i; + while (endA < a.size() && a.at(endA).isDigit() == digitA) + ++endA; + int endB = j; + while (endB < b.size() && b.at(endB).isDigit() == digitB) + ++endB; + const QStringView runA = QStringView(a).mid(i, endA - i); + const QStringView runB = QStringView(b).mid(j, endB - j); + + int result = 0; + if (digitA && digitB) { + QStringView numA = runA; + QStringView numB = runB; + while (numA.size() > 1 && numA.front() == u'0') + numA = numA.mid(1); + while (numB.size() > 1 && numB.front() == u'0') + numB = numB.mid(1); + result = numA.size() == numB.size() + ? numA.compare(numB) + : (numA.size() < numB.size() ? -1 : 1); + } else { + result = collator.compare(runA, runB); + } + if (result != 0) + return result < 0 ? -1 : 1; + i = endA; + j = endB; + } + if (i < a.size()) + return 1; + if (j < b.size()) + return -1; + return collator.compare(a, b); + } +} // namespace + +InstanceFilterModel::InstanceFilterModel(QObject* parent) + : QSortFilterProxyModel(parent) +{ + // Numbers are handled by naturalCompare(); numeric mode stays on for + // backends that honour it, so a tie between runs orders the same way. + m_naturalSort.setNumericMode(true); + m_naturalSort.setCaseSensitivity(Qt::CaseInsensitive); + // FIXME: same as the widget proxy -- use loaded translation as source of + // locale instead, hook this up to translation changes. + m_naturalSort.setLocale(QLocale::system()); + + // rowCount() changes on all four of these; simplest to treat them all as + // "the filtered count might have moved" rather than track it by hand. + connect(this, &QAbstractItemModel::rowsInserted, this, + &InstanceFilterModel::countChanged); + connect(this, &QAbstractItemModel::rowsRemoved, this, + &InstanceFilterModel::countChanged); + connect(this, &QAbstractItemModel::modelReset, this, + &InstanceFilterModel::countChanged); + connect(this, &QAbstractItemModel::layoutChanged, this, + &InstanceFilterModel::countChanged); + + // groups and firstId follow the same row changes, plus plain data + // changes: renaming or regrouping an instance moves neither row count. + connect(this, &QAbstractItemModel::rowsInserted, this, + &InstanceFilterModel::refreshDerived); + connect(this, &QAbstractItemModel::rowsRemoved, this, + &InstanceFilterModel::refreshDerived); + connect(this, &QAbstractItemModel::modelReset, this, + &InstanceFilterModel::refreshDerived); + connect(this, &QAbstractItemModel::layoutChanged, this, + &InstanceFilterModel::refreshDerived); + connect(this, &QAbstractItemModel::dataChanged, this, + &InstanceFilterModel::refreshDerived); +} + +QString InstanceFilterModel::filterText() const +{ + return m_filterText; +} + +void InstanceFilterModel::setFilterText(const QString& text) +{ + if (m_filterText == text) { + return; + } + m_filterText = text; + Q_EMIT filterTextChanged(); + invalidateFilter(); +} + +QString InstanceFilterModel::group() const +{ + return m_group; +} + +void InstanceFilterModel::setGroup(const QString& group) +{ + if (m_group == group) { + return; + } + m_group = group; + Q_EMIT groupChanged(); + invalidateFilter(); +} + +int InstanceFilterModel::count() const +{ + return rowCount(); +} + +bool InstanceFilterModel::exactGroup() const +{ + return m_exactGroup; +} + +void InstanceFilterModel::setExactGroup(bool exact) +{ + if (m_exactGroup == exact) { + return; + } + m_exactGroup = exact; + Q_EMIT exactGroupChanged(); + invalidateFilter(); +} + +QString InstanceFilterModel::instanceId() const +{ + return m_instanceId; +} + +void InstanceFilterModel::setInstanceId(const QString& id) +{ + if (m_instanceId == id) { + return; + } + m_instanceId = id; + Q_EMIT instanceIdChanged(); + invalidateFilter(); +} + +bool InstanceFilterModel::recentFirst() const +{ + return m_recentFirst; +} + +void InstanceFilterModel::setRecentFirst(bool recentFirst) +{ + if (m_recentFirst == recentFirst) { + return; + } + m_recentFirst = recentFirst; + Q_EMIT recentFirstChanged(); + // Both the ordering and the "never launched" filter depend on it. + invalidate(); +} + +QStringList InstanceFilterModel::groups() const +{ + return m_groups; +} + +QString InstanceFilterModel::firstId() const +{ + return m_firstId; +} + +void InstanceFilterModel::refreshDerived() +{ + QStringList groups; + const int rows = rowCount(); + for (int row = 0; row < rows; ++row) { + const QString group = index(row, 0).data(m_groupRole).toString(); + if (!groups.contains(group)) { + groups.append(group); + } + } + if (groups != m_groups) { + m_groups = groups; + Q_EMIT groupsChanged(); + } + + const QString firstId = (rows > 0 && m_idRole >= 0) + ? index(0, 0).data(m_idRole).toString() + : QString(); + if (firstId != m_firstId) { + m_firstId = firstId; + Q_EMIT firstIdChanged(); + } +} + +void InstanceFilterModel::setSourceModel(QAbstractItemModel* sourceModel) +{ + /* Roles first: the base class filters every source row as soon as it + * has the model, and a filter set before this call (recentFirst, + * instanceId) would otherwise judge those rows with unresolved roles + * and drop all of them. */ + m_nameRole = roleByName(sourceModel, "name", Qt::DisplayRole); + m_groupRole = roleByName(sourceModel, "group", Qt::UserRole); + m_lastLaunchRole = roleByName(sourceModel, "lastLaunch", -1); + m_idRole = roleByName(sourceModel, "instanceId", -1); + m_totalTimePlayedRole = roleByName(sourceModel, "totalTimePlayed", -1); + QSortFilterProxyModel::setSourceModel(sourceModel); + + /* A QSortFilterProxyModel does not sort until something asks it to, and a + * caller that forgets sort(0) shows instances in discovery order. Asked + * for here, after the roles above are resolved, so the very first + * ordering already uses them rather than the defaults. */ + sort(0); + refreshDerived(); +} + +bool InstanceFilterModel::filterAcceptsRow( + int sourceRow, const QModelIndex& sourceParent) const +{ + if (!sourceModel()) { + return false; + } + const QModelIndex index = + sourceModel()->index(sourceRow, 0, sourceParent); + if (!m_instanceId.isEmpty() && + (m_idRole < 0 || index.data(m_idRole).toString() != m_instanceId)) { + return false; + } + if ((m_exactGroup || !m_group.isEmpty()) && + index.data(m_groupRole).toString() != m_group) { + return false; + } + if (m_recentFirst && + (m_lastLaunchRole < 0 || + index.data(m_lastLaunchRole).toLongLong() <= 0)) { + return false; + } + if (!m_filterText.isEmpty() && + !index.data(m_nameRole).toString().contains(m_filterText, + Qt::CaseInsensitive)) { + return false; + } + return true; +} + +bool InstanceFilterModel::lessThan(const QModelIndex& left, + const QModelIndex& right) const +{ + if (m_recentFirst && m_lastLaunchRole >= 0) { + return left.data(m_lastLaunchRole).toLongLong() > + right.data(m_lastLaunchRole).toLongLong(); + } + const QString leftGroup = left.data(m_groupRole).toString(); + const QString rightGroup = right.data(m_groupRole).toString(); + if (leftGroup == rightGroup) { + return subSortLessThan(left, right); + } else { + // FIXME: ported as-is from the widget proxy -- real group ordering + // happens in InstanceView::updateGeometries() on that side, not + // here. Carrying the same gap over to both UIs beats fixing it in + // only one of them. + auto result = leftGroup.localeAwareCompare(rightGroup); + if (result == 0) { + return subSortLessThan(left, right); + } + return result < 0; + } +} + +QString InstanceFilterModel::sortModeSetting() const +{ + auto* context = LauncherContext::instance(); + return context ? context->settings()->get("InstSortMode").toString() + : QString(); +} + +bool InstanceFilterModel::subSortLessThan(const QModelIndex& left, + const QModelIndex& right) const +{ + const QString sortMode = sortModeSetting(); + if (m_lastLaunchRole >= 0 && sortMode == "LastLaunch") { + return left.data(m_lastLaunchRole).toLongLong() > + right.data(m_lastLaunchRole).toLongLong(); + } + // "Time played" is a QML-library-only addition to InstSortMode (the + // classic widget page only ever writes "Name"/"LastLaunch"), so it + // falls back to name sorting the same way an unresolved role does. + if (m_totalTimePlayedRole >= 0 && sortMode == "TotalTimePlayed") { + return left.data(m_totalTimePlayedRole).toLongLong() > + right.data(m_totalTimePlayedRole).toLongLong(); + } + return naturalCompare(m_naturalSort, left.data(m_nameRole).toString(), + right.data(m_nameRole).toString()) < 0; +} diff --git a/launcher/models/InstanceFilterModel.h b/launcher/models/InstanceFilterModel.h new file mode 100644 index 00000000..b2891ae9 --- /dev/null +++ b/launcher/models/InstanceFilterModel.h @@ -0,0 +1,159 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include + +/* + * QML replacement for the widget grid's proxy + * (ui/instanceview/InstanceProxyModel.h). QML has no QItemSelectionModel and + * cannot reach into the widget layer, so this is a second proxy rather than a + * shared one -- but it reproduces that proxy's ordering exactly (grouped, + * locale-sorted groups, natural-sort names within a group, with the same + * "InstSortMode" last-launch override) so that switching between the widget + * grid and the QML one never reorders anyone's instances. The Library + * toolbar's own "Time played" sort is a QML-only third value + * ("TotalTimePlayed") on that same setting -- the classic widget page never + * writes it, but it re-sorts both UIs' proxies the same way "LastLaunch" + * already does. + * + * QtCore/QtGui only, same rule as the rest of the core: no QtWidgets, nothing + * from ui/. In particular this cannot resolve icons the way the old proxy did + * (APPLICATION->icons()->getIcon() in its data() override) -- APPLICATION is + * a QApplication and off limits here. iconKey is forwarded as the plain + * string InstanceList already exposes it as; whatever turns that into a + * picture is the QML delegate's problem, not this proxy's. + * + * The name, group, last-launch and total-time-played roles are looked up by + * NAME from the source model's roleNames() rather than assumed to be + * Qt::DisplayRole/GroupRole/LastLaunchRole, so this works against + * InstanceList's real role numbers (see InstanceList.h -- GroupRole is + * pinned to Qt::UserRole, the QML-only roles start at Qt::UserRole + 10) and + * against any other model that merely exposes the same names (see + * InstanceFilterModel_test.cpp, which deliberately numbers them + * differently). + */ +class InstanceFilterModel : public QSortFilterProxyModel +{ + Q_OBJECT + + Q_PROPERTY(QString filterText READ filterText WRITE setFilterText NOTIFY + filterTextChanged) + Q_PROPERTY(QString group READ group WRITE setGroup NOTIFY groupChanged) + Q_PROPERTY(int count READ count NOTIFY countChanged) + /* When set, `group` is matched exactly -- including the empty string, + * which then means "ungrouped only" instead of "every group". This is + * what a per-group section of the library needs. */ + Q_PROPERTY(bool exactGroup READ exactGroup WRITE setExactGroup NOTIFY + exactGroupChanged) + /// When non-empty, only the instance with this id passes the filter. + Q_PROPERTY(QString instanceId READ instanceId WRITE setInstanceId NOTIFY + instanceIdChanged) + /* Most recently launched first, ignoring groups and the InstSortMode + * setting, and instances that were never launched are left out. */ + Q_PROPERTY(bool recentFirst READ recentFirst WRITE setRecentFirst NOTIFY + recentFirstChanged) + /// Distinct groups of the rows that pass the filter, in row order. + Q_PROPERTY(QStringList groups READ groups NOTIFY groupsChanged) + /// Id of the first row after filtering and sorting; empty when none. + Q_PROPERTY(QString firstId READ firstId NOTIFY firstIdChanged) + + public: + explicit InstanceFilterModel(QObject* parent = nullptr); + + QString filterText() const; + void setFilterText(const QString& text); + + QString group() const; + void setGroup(const QString& group); + + /// Row count after filtering -- what QML needs to decide an empty state. + int count() const; + + bool exactGroup() const; + void setExactGroup(bool exact); + + QString instanceId() const; + void setInstanceId(const QString& id); + + bool recentFirst() const; + void setRecentFirst(bool recentFirst); + + QStringList groups() const; + QString firstId() const; + + void setSourceModel(QAbstractItemModel* sourceModel) override; + + signals: + void filterTextChanged(); + void groupChanged(); + void countChanged(); + void exactGroupChanged(); + void instanceIdChanged(); + void recentFirstChanged(); + void groupsChanged(); + void firstIdChanged(); + + protected: + bool filterAcceptsRow(int sourceRow, + const QModelIndex& sourceParent) const override; + bool lessThan(const QModelIndex& left, + const QModelIndex& right) const override; + + /* The "InstSortMode" setting's current value ("Name", "LastLaunch" or + * the QML library's own "TotalTimePlayed"), read from the live + * LauncherContext. A seam purely for tests: subSortLessThan() cannot + * otherwise be exercised without constructing a whole LauncherContext, + * so a test subclass overrides this instead. */ + virtual QString sortModeSetting() const; + + private: + bool subSortLessThan(const QModelIndex& left, + const QModelIndex& right) const; + /// Recomputes groups and firstId, emitting only what actually moved. + void refreshDerived(); + + QCollator m_naturalSort; + QString m_filterText; + QString m_group; + bool m_exactGroup = false; + QString m_instanceId; + bool m_recentFirst = false; + QStringList m_groups; + QString m_firstId; + + /* Resolved in setSourceModel(). The fallbacks are the roles Qt itself + * gives the same meaning by convention, so a source model that never + * names its roles still filters and sorts by something sane instead of + * silently matching nothing. -1 for lastLaunch means "this source has no + * such role", which makes the LastLaunch sort mode fall back to name + * sorting instead of comparing garbage. */ + int m_nameRole = Qt::DisplayRole; + int m_groupRole = Qt::UserRole; + int m_lastLaunchRole = -1; + int m_idRole = -1; + /// -1 when the source has no such role, same convention as + /// m_lastLaunchRole -- the "Time played" InstSortMode value then falls + /// back to name sorting instead of comparing garbage. + int m_totalTimePlayedRole = -1; +}; diff --git a/launcher/models/InstanceFilterModel_test.cpp b/launcher/models/InstanceFilterModel_test.cpp new file mode 100644 index 00000000..d7db174d --- /dev/null +++ b/launcher/models/InstanceFilterModel_test.cpp @@ -0,0 +1,399 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include +#include + +#include "models/InstanceFilterModel.h" + +namespace +{ + +/* + * Stand-in for InstanceList: exposes the same role NAMES (name, group, + * instanceId, lastLaunch) but deliberately different role NUMBERS. A test + * that passes against this only passes because InstanceFilterModel resolves + * roles by name, not because it happens to reuse InstanceList's own numbers. + */ +class FakeInstanceModel : public QAbstractListModel +{ + public: + struct Row { + QString id; + QString name; + QString group; + qint64 lastLaunch = 0; + qint64 totalTimePlayed = 0; + }; + + enum Roles { IdRole = Qt::UserRole + 1, NameRole, GroupRole, LastLaunchRole, TotalTimePlayedRole }; + + explicit FakeInstanceModel(QList rows, QObject* parent = nullptr) + : QAbstractListModel(parent), m_rows(std::move(rows)) + { + } + + int rowCount(const QModelIndex& parent = QModelIndex()) const override + { + return parent.isValid() ? 0 : m_rows.count(); + } + + QVariant data(const QModelIndex& index, int role) const override + { + if (!index.isValid() || index.row() >= m_rows.count()) { + return QVariant(); + } + const Row& row = m_rows.at(index.row()); + switch (role) { + case IdRole: + return row.id; + case NameRole: + return row.name; + case GroupRole: + return row.group; + case LastLaunchRole: + return row.lastLaunch; + case TotalTimePlayedRole: + return row.totalTimePlayed; + default: + return QVariant(); + } + } + + QHash roleNames() const override + { + return { + { IdRole, "instanceId" }, + { NameRole, "name" }, + { GroupRole, "group" }, + { LastLaunchRole, "lastLaunch" }, + { TotalTimePlayedRole, "totalTimePlayed" }, + }; + } + + private: + QList m_rows; +}; + +/* + * subSortLessThan() reads its sort mode off the live LauncherContext, which + * nothing in this test binary constructs -- see sortModeSetting()'s own + * comment. This override stands in for it so the comparator itself (name + * sort, "LastLaunch" and "TotalTimePlayed") can be exercised directly. + */ +class SortModeInstanceFilterModel : public InstanceFilterModel +{ + public: + QString mode; + + protected: + QString sortModeSetting() const override { return mode; } +}; + +} // namespace + +class InstanceFilterModelTest : public QObject +{ + Q_OBJECT + + private slots: + /// "Pack 2" has to sort before "Pack 10" -- a plain string compare would + /// put "Pack 10" first, which is the exact bug QCollator numeric mode + /// exists to avoid. + void test_naturalSort_ordersNumericSuffixesNumerically() + { + FakeInstanceModel source({ + { "b", "Pack 10", "", 0 }, + { "a", "Pack 2", "", 0 }, + }); + InstanceFilterModel filter; + filter.setSourceModel(&source); + filter.sort(0); + + QCOMPARE(filter.index(0, 0).data(FakeInstanceModel::NameRole).toString(), + QString("Pack 2")); + QCOMPARE(filter.index(1, 0).data(FakeInstanceModel::NameRole).toString(), + QString("Pack 10")); + } + + /// Numbers compare by value whatever the collator backend supports: + /// leading zeros, numbers longer than any integer type, and case + /// differences in the text between them. + void test_naturalSort_doesNotRelyOnCollatorNumericMode() + { + FakeInstanceModel source({ + { "a", "pack 100000000000000000000", "", 0 }, + { "b", "Pack 10", "", 0 }, + { "c", "Pack 9", "", 0 }, + { "d", "Pack 010b", "", 0 }, + { "e", "Pack 10a", "", 0 }, + { "f", "Pack", "", 0 }, + }); + InstanceFilterModel filter; + filter.setSourceModel(&source); + filter.sort(0); + + const QStringList expected = { "Pack", "Pack 9", "Pack 10", + "Pack 10a", "Pack 010b", + "pack 100000000000000000000" }; + QStringList actual; + for (int row = 0; row < filter.rowCount(); ++row) + actual << filter.index(row, 0) + .data(FakeInstanceModel::NameRole) + .toString(); + QCOMPARE(actual, expected); + } + + /// Same-group rows fall through to natural sort; different-group rows + /// sort by group first, mirroring the widget proxy's lessThan(). + void test_lessThan_groupsSortBeforeName() + { + FakeInstanceModel source({ + { "a", "Alpha", "Zeta Group", 0 }, + { "b", "Zulu", "Alpha Group", 0 }, + }); + InstanceFilterModel filter; + filter.setSourceModel(&source); + filter.sort(0); + + // "Alpha Group" sorts before "Zeta Group", so "Zulu" (in Alpha + // Group) comes first even though "Alpha" < "Zulu" by name. + QCOMPARE(filter.index(0, 0).data(FakeInstanceModel::NameRole).toString(), + QString("Zulu")); + QCOMPARE(filter.index(1, 0).data(FakeInstanceModel::NameRole).toString(), + QString("Alpha")); + } + + void test_filterText_matchesNameCaseInsensitively_andUpdatesCount() + { + FakeInstanceModel source({ + { "a", "Pack Alpha", "", 0 }, + { "b", "Pack Beta", "", 0 }, + { "c", "Something Else", "", 0 }, + }); + InstanceFilterModel filter; + filter.setSourceModel(&source); + filter.sort(0); + QCOMPARE(filter.count(), 3); + + QSignalSpy countSpy(&filter, &InstanceFilterModel::countChanged); + filter.setFilterText("PACK"); + QVERIFY(!countSpy.isEmpty()); + QCOMPARE(filter.count(), 2); + + filter.setFilterText("pack alpha"); + QCOMPARE(filter.count(), 1); + QCOMPARE( + filter.index(0, 0).data(FakeInstanceModel::NameRole).toString(), + QString("Pack Alpha")); + + filter.setFilterText(QString()); + QCOMPARE(filter.count(), 3); + } + + /// Empty group means "no filter"; a non-empty one keeps only that group. + void test_group_emptyMeansAllGroups_otherwiseOnlyThatGroup() + { + FakeInstanceModel source({ + { "a", "One", "Modpacks", 0 }, + { "b", "Two", "Vanilla", 0 }, + { "c", "Three", "Modpacks", 0 }, + }); + InstanceFilterModel filter; + filter.setSourceModel(&source); + filter.sort(0); + QCOMPARE(filter.count(), 3); + + filter.setGroup("Modpacks"); + QCOMPARE(filter.count(), 2); + for (int i = 0; i < filter.rowCount(); ++i) { + QCOMPARE( + filter.index(i, 0).data(FakeInstanceModel::GroupRole).toString(), + QString("Modpacks")); + } + + filter.setGroup(QString()); + QCOMPARE(filter.count(), 3); + } + + /// exactGroup turns the empty group into "ungrouped only", which is + /// what one library section needs; groups lists what is left, in order. + void test_exactGroup_emptyMeansUngrouped_andGroupsFollowRows() + { + FakeInstanceModel source({ + { "a", "One", "Modpacks", 0 }, + { "b", "Two", "", 0 }, + { "c", "Three", "Vanilla", 0 }, + }); + InstanceFilterModel filter; + filter.setSourceModel(&source); + QCOMPARE(filter.groups(), QStringList({ "", "Modpacks", "Vanilla" })); + + QSignalSpy groupsSpy(&filter, &InstanceFilterModel::groupsChanged); + filter.setExactGroup(true); + QCOMPARE(filter.count(), 1); + QCOMPARE(filter.firstId(), QString("b")); + QCOMPARE(filter.groups(), QStringList({ "" })); + QCOMPARE(groupsSpy.count(), 1); + } + + /// recentFirst: newest launch first, never-launched instances left out, + /// groups ignored for ordering. + void test_recentFirst_ordersByLastLaunch_andDropsNeverLaunched() + { + FakeInstanceModel source({ + { "a", "One", "", 100 }, + { "b", "Two", "Vanilla", 0 }, + { "c", "Three", "Vanilla", 300 }, + { "d", "Four", "Modpacks", 200 }, + }); + InstanceFilterModel filter; + filter.setSourceModel(&source); + // Grouped order puts the ungrouped "a" first... + QCOMPARE(filter.firstId(), QString("a")); + QSignalSpy firstSpy(&filter, &InstanceFilterModel::firstIdChanged); + filter.setRecentFirst(true); + + QCOMPARE(filter.count(), 3); + QCOMPARE(filter.index(0, 0).data(FakeInstanceModel::IdRole).toString(), + QString("c")); + QCOMPARE(filter.index(1, 0).data(FakeInstanceModel::IdRole).toString(), + QString("d")); + QCOMPARE(filter.index(2, 0).data(FakeInstanceModel::IdRole).toString(), + QString("a")); + // ...and the switch has to be announced, or a binding on firstId + // keeps showing the old instance. + QCOMPARE(filter.firstId(), QString("c")); + QCOMPARE(firstSpy.count(), 1); + } + + /// Filters set before the source arrives must already see its roles -- + /// the shell configures its recent and hero proxies exactly this way. + void test_filtersSetBeforeSource_applyToFirstRows() + { + FakeInstanceModel source({ + { "a", "One", "", 100 }, + { "b", "Two", "", 0 }, + { "c", "Three", "", 300 }, + }); + InstanceFilterModel recent; + recent.setRecentFirst(true); + recent.setSourceModel(&source); + QCOMPARE(recent.count(), 2); + QCOMPARE(recent.firstId(), QString("c")); + + InstanceFilterModel single; + single.setInstanceId("b"); + single.setSourceModel(&source); + QCOMPARE(single.count(), 1); + QCOMPARE(single.firstId(), QString("b")); + } + + /// instanceId narrows the proxy to one row -- a single-instance view. + void test_instanceId_keepsOnlyThatInstance() + { + FakeInstanceModel source({ + { "a", "One", "Modpacks", 0 }, + { "b", "Two", "Vanilla", 0 }, + }); + InstanceFilterModel filter; + filter.setSourceModel(&source); + filter.setInstanceId("b"); + QCOMPARE(filter.count(), 1); + QCOMPARE(filter.firstId(), QString("b")); + + filter.setInstanceId("missing"); + QCOMPARE(filter.count(), 0); + QCOMPARE(filter.firstId(), QString()); + } + + /// QML delegates reach instanceId/name/group/lastLaunch through the + /// proxy by name, so the proxy's roleNames() has to be the source + /// model's, not QSortFilterProxyModel's own default. + void test_roleNames_areForwardedFromSourceModel() + { + FakeInstanceModel source({}); + InstanceFilterModel filter; + filter.setSourceModel(&source); + + QCOMPARE(filter.roleNames(), source.roleNames()); + } + + /// Within a group, "InstSortMode" == "TotalTimePlayed" (the Library + /// toolbar's "Time played" option) orders most-played first. + void test_subSort_totalTimePlayed_ordersMostPlayedFirst() + { + FakeInstanceModel source({ + { "a", "Alpha", "", 100 }, + { "b", "Beta", "", 0, 500 }, + { "c", "Gamma", "", 0, 200 }, + }); + SortModeInstanceFilterModel filter; + filter.mode = "TotalTimePlayed"; + filter.setSourceModel(&source); + filter.sort(0); + + QCOMPARE(filter.index(0, 0).data(FakeInstanceModel::IdRole).toString(), QString("b")); + QCOMPARE(filter.index(1, 0).data(FakeInstanceModel::IdRole).toString(), QString("c")); + QCOMPARE(filter.index(2, 0).data(FakeInstanceModel::IdRole).toString(), QString("a")); + } + + /// Same "InstSortMode" seam, exercising the pre-existing "LastLaunch" + /// value: most recently played first, same as recentFirst's ordering + /// but as the in-group tie-break rather than a top-level filter. + void test_subSort_lastLaunch_ordersMostRecentFirst() + { + FakeInstanceModel source({ + { "a", "Alpha", "", 100 }, + { "b", "Beta", "", 300 }, + { "c", "Gamma", "", 200 }, + }); + SortModeInstanceFilterModel filter; + filter.mode = "LastLaunch"; + filter.setSourceModel(&source); + filter.sort(0); + + QCOMPARE(filter.index(0, 0).data(FakeInstanceModel::IdRole).toString(), QString("b")); + QCOMPARE(filter.index(1, 0).data(FakeInstanceModel::IdRole).toString(), QString("c")); + QCOMPARE(filter.index(2, 0).data(FakeInstanceModel::IdRole).toString(), QString("a")); + } + + /// An unrecognised (or empty, i.e. "Name") sort mode always falls back + /// to natural name order, even once totalTimePlayed/lastLaunch roles + /// exist and carry data -- "Time played" must never leak in silently. + void test_subSort_unknownSortMode_fallsBackToNaturalNameOrder() + { + FakeInstanceModel source({ + { "a", "Pack 10", "", 300, 300 }, + { "b", "Pack 2", "", 100, 900 }, + }); + SortModeInstanceFilterModel filter; + filter.mode = "Name"; + filter.setSourceModel(&source); + filter.sort(0); + + QCOMPARE(filter.index(0, 0).data(FakeInstanceModel::NameRole).toString(), QString("Pack 2")); + QCOMPARE(filter.index(1, 0).data(FakeInstanceModel::NameRole).toString(), QString("Pack 10")); + } +}; + +QTEST_GUILESS_MAIN(InstanceFilterModelTest) + +#include "InstanceFilterModel_test.moc" diff --git a/launcher/models/KeyValueFilterModel.cpp b/launcher/models/KeyValueFilterModel.cpp new file mode 100644 index 00000000..c6ea9016 --- /dev/null +++ b/launcher/models/KeyValueFilterModel.cpp @@ -0,0 +1,54 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "KeyValueFilterModel.h" + +KeyValueFilterModel::KeyValueFilterModel(QObject* parent) + : QSortFilterProxyModel(parent) +{ + setDynamicSortFilter(true); +} + +void KeyValueFilterModel::setFilterText(const QString& text) +{ + if (m_filterText == text) { + return; + } + m_filterText = text; + emit filterTextChanged(); + invalidateFilter(); +} + +bool KeyValueFilterModel::filterAcceptsRow(int sourceRow, + const QModelIndex& sourceParent) const +{ + if (m_filterText.isEmpty() || !sourceModel()) { + return true; + } + const auto index = sourceModel()->index(sourceRow, 0, sourceParent); + const auto roles = sourceModel()->roleNames(); + for (auto it = roles.constBegin(); it != roles.constEnd(); ++it) { + const QVariant value = index.data(it.key()); + if (value.canConvert() && + value.toString().contains(m_filterText, Qt::CaseInsensitive)) { + return true; + } + } + return false; +} diff --git a/launcher/models/KeyValueFilterModel.h b/launcher/models/KeyValueFilterModel.h new file mode 100644 index 00000000..fecc4fe3 --- /dev/null +++ b/launcher/models/KeyValueFilterModel.h @@ -0,0 +1,63 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include + +/* + * Filters a `key`/`value`-role source model (GameOptions today) to rows + * where either role contains `filterText`, case-insensitively - a QML + * search box's WRITE target, since QSortFilterProxyModel's own + * setFilterFixedString()/setFilterKeyColumn() are plain C++ methods QML + * cannot call. Empty `filterText` keeps every row. + * + * Not specific to GameOptions - filterAcceptsRow() falls back to matching + * every role a row's roleNames() has string data for as soon as it sees + * something other than key/value, so it stays correct if it is ever pointed + * at a different two-column model. QtCore only, same rule as the rest of + * models/: no QtWidgets, no ui/. + */ +class KeyValueFilterModel : public QSortFilterProxyModel +{ + Q_OBJECT + + Q_PROPERTY(QString filterText READ filterText WRITE setFilterText NOTIFY + filterTextChanged) + + public: + explicit KeyValueFilterModel(QObject* parent = nullptr); + + QString filterText() const + { + return m_filterText; + } + void setFilterText(const QString& text); + + signals: + void filterTextChanged(); + + protected: + bool filterAcceptsRow(int sourceRow, + const QModelIndex& sourceParent) const override; + + private: + QString m_filterText; +}; diff --git a/launcher/models/KeyValueFilterModel_test.cpp b/launcher/models/KeyValueFilterModel_test.cpp new file mode 100644 index 00000000..86a8622e --- /dev/null +++ b/launcher/models/KeyValueFilterModel_test.cpp @@ -0,0 +1,117 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include +#include +#include + +#include "minecraft/gameoptions/GameOptions.h" +#include "models/KeyValueFilterModel.h" + +namespace +{ +bool writeOptions(const QString& path, const QByteArray& contents) +{ + QFile file(path); + if (!file.open(QIODevice::WriteOnly)) { + return false; + } + return file.write(contents) == contents.size(); +} +} // namespace + +class KeyValueFilterModelTest : public QObject +{ + Q_OBJECT + private slots: + + void emptyFilterKeepsEveryRow() + { + QTemporaryDir dir; + QVERIFY(dir.isValid()); + const QString path = QDir(dir.path()).filePath("options.txt"); + QVERIFY(writeOptions(path, "renderDistance:12\nfov:0\n")); + + GameOptions source(path); + KeyValueFilterModel filter; + filter.setSourceModel(&source); + + QCOMPARE(filter.rowCount(), 2); + } + + void filtersByKeyCaseInsensitively() + { + QTemporaryDir dir; + QVERIFY(dir.isValid()); + const QString path = QDir(dir.path()).filePath("options.txt"); + QVERIFY(writeOptions(path, "renderDistance:12\nfov:0\nguiScale:2\n")); + + GameOptions source(path); + KeyValueFilterModel filter; + filter.setSourceModel(&source); + + filter.setFilterText("render"); + QCOMPARE(filter.rowCount(), 1); + QCOMPARE(filter.data(filter.index(0, 0), GameOptions::KeyRole).toString(), + QStringLiteral("renderDistance")); + + filter.setFilterText("RENDER"); + QCOMPARE(filter.rowCount(), 1); + } + + void filtersByValueToo() + { + QTemporaryDir dir; + QVERIFY(dir.isValid()); + const QString path = QDir(dir.path()).filePath("options.txt"); + QVERIFY(writeOptions(path, "renderDistance:12\nfov:70\n")); + + GameOptions source(path); + KeyValueFilterModel filter; + filter.setSourceModel(&source); + + filter.setFilterText("70"); + QCOMPARE(filter.rowCount(), 1); + QCOMPARE(filter.data(filter.index(0, 0), GameOptions::ValueRole).toString(), + QStringLiteral("70")); + } + + void noMatchesLeavesModelEmpty() + { + QTemporaryDir dir; + QVERIFY(dir.isValid()); + const QString path = QDir(dir.path()).filePath("options.txt"); + QVERIFY(writeOptions(path, "renderDistance:12\n")); + + GameOptions source(path); + KeyValueFilterModel filter; + filter.setSourceModel(&source); + + filter.setFilterText("doesNotExist"); + QCOMPARE(filter.rowCount(), 0); + + filter.setFilterText(""); + QCOMPARE(filter.rowCount(), 1); + } +}; + +QTEST_GUILESS_MAIN(KeyValueFilterModelTest) + +#include "KeyValueFilterModel_test.moc" diff --git a/launcher/models/LoaderInstaller.cpp b/launcher/models/LoaderInstaller.cpp new file mode 100644 index 00000000..ee9909e8 --- /dev/null +++ b/launcher/models/LoaderInstaller.cpp @@ -0,0 +1,200 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "LoaderInstaller.h" + +#include +#include + +#include "Version.h" +#include "core/LauncherContext.h" +#include "meta/Index.h" +#include "meta/VersionList.h" +#include "minecraft/Component.h" +#include "minecraft/PackProfile.h" +#include "models/NewInstanceController.h" + +LoaderInstaller::LoaderInstaller(PackProfile* profile, QObject* parent) + : QObject(parent), m_profile(profile), + // Parented to `this`, not left parentless like a bare local proxy + // would be: QmlShell::newInstance() spells out why in its own + // comment on NewInstanceController::minecraftVersions()/ + // loaderVersions() - a parentless QObject handed to QML through a + // property is fair game for the engine to garbage-collect out from + // under this class's own std::unique_ptr the first time QML touches + // it. The unique_ptr still runs first on destruction (member before + // base class), so this is not a double free - see + // InstanceDetails::m_contentBrowser for the same shape. + m_versions(std::make_unique(this)) +{ +} + +LoaderInstaller::~LoaderInstaller() = default; + +QVariantList LoaderInstaller::loaders() const +{ + QVariantList out; + for (const ModLoaderInfo& loader : knownModLoaders()) { + QVariantMap entry; + entry[QStringLiteral("uid")] = loader.uid; + entry[QStringLiteral("brandName")] = loader.brandName; + entry[QStringLiteral("iconName")] = loader.iconName; + out.append(entry); + } + return out; +} + +QObject* LoaderInstaller::versions() const +{ + return m_versions.get(); +} + +QString LoaderInstaller::installedVersion() const +{ + if (m_selectedUid.isEmpty() || !m_profile) { + return QString(); + } + return m_profile->getComponentVersion(m_selectedUid); +} + +QString LoaderInstaller::conflictName() const +{ + const ModLoaderInfo* loader = modLoaderForUid(m_selectedUid); + if (!loader || !m_profile) { + return QString(); + } + for (const QString& conflictUid : loader->conflictsWith) { + Component* conflict = m_profile->getComponent(conflictUid); + // A disabled or already-customized component is either harmless + // or not this class's business to touch - see install()'s own + // comment on why customized components are left alone. + if (conflict && conflict->isEnabled() && !conflict->isCustom()) { + return conflict->getName(); + } + } + return QString(); +} + +void LoaderInstaller::selectLoader(const QString& uid) +{ + m_selectedUid = uid; + m_supported = true; + m_unsupportedReason.clear(); + + const ModLoaderInfo* loader = modLoaderForUid(uid); + if (!loader || !m_profile) { + m_versions->setSourceModel(nullptr); + emit selectedUidChanged(); + return; + } + + const QString mcVersion = + m_profile->getComponentVersion(QStringLiteral("net.minecraft")); + + /* A stated floor means the metadata cannot rule this loader out by + * itself - see ModLoaderInfo's own class comment - so there is + * nothing to load at all. Mirrors LoaderVersionPage's constructor. */ + if (!loader->earliestMinecraft.isEmpty() && + Version(mcVersion) < Version(loader->earliestMinecraft)) { + m_supported = false; + m_unsupportedReason = + tr("%1 does not run on Minecraft %2. The earliest version it " + "supports is %3.") + .arg(loader->brandName, mcVersion, loader->earliestMinecraft); + m_versions->setSourceModel(nullptr); + emit selectedUidChanged(); + return; + } + + /* setMinecraftVersion() before setSourceModel(), same order + * NewInstanceController::refreshLoaderSource() uses: the filter is + * already correct by the time the new source model's rows are first + * evaluated. setSourceModel() itself starts the download if needed + * (VersionListLoadingProxy::startLoadIfNeeded()) and settles + * `versions.loading`/`versions.error`. */ + auto list = LAUNCHER->metadataIndex()->get(uid); + m_versions->setMinecraftVersion(mcVersion); + m_versions->setSourceModel(list.get()); + + emit selectedUidChanged(); +} + +QList LoaderInstaller::installSequence( + int conflictCount) +{ + QList steps; + for (int i = 0; i < conflictCount; ++i) { + steps.append(InstallStep::DisableConflict); + } + steps.append(InstallStep::EnableSelected); + steps.append(InstallStep::ChangeVersion); + return steps; +} + +bool LoaderInstaller::install(const QString& versionId) +{ + const ModLoaderInfo* loader = modLoaderForUid(m_selectedUid); + if (!loader || !m_profile || versionId.isEmpty()) { + return false; + } + if (m_profile->busy()) { + return false; + } + + /* Collect conflicts up front rather than disabling them as they are + * found, so installSequence() below can turn "how many" into the + * fixed step order without touching the profile itself - see the + * class comment for why a conflict is turned off rather than asked + * about, or removed. */ + QStringList conflictsToDisable; + for (const QString& conflictUid : loader->conflictsWith) { + Component* conflict = m_profile->getComponent(conflictUid); + if (conflict && conflict->isEnabled() && !conflict->isCustom()) { + conflictsToDisable.append(conflictUid); + } + } + + /* installSequence()'s own comment explains why the selected loader + * must be enabled (step EnableSelected) before its version is changed + * (step ChangeVersion, which triggers PackProfile::resolve()) - + * mirrors InstallLoaderDialog::applySelection(). EnableSelected is a + * no-op when the component doesn't exist yet (brand new loader): + * setComponentEnabled() fails quietly since there is nothing to + * enable yet, and ChangeVersion creates it enabled (new components + * start enabled). */ + for (InstallStep step : installSequence(conflictsToDisable.size())) { + switch (step) { + case InstallStep::DisableConflict: + m_profile->setComponentEnabled(conflictsToDisable.takeFirst(), + false); + break; + case InstallStep::EnableSelected: + m_profile->setComponentEnabled(m_selectedUid, true); + break; + case InstallStep::ChangeVersion: + if (!m_profile->changeComponentVersion(m_selectedUid, + versionId)) { + return false; + } + break; + } + } + + return true; +} diff --git a/launcher/models/LoaderInstaller.h b/launcher/models/LoaderInstaller.h new file mode 100644 index 00000000..7d4d2991 --- /dev/null +++ b/launcher/models/LoaderInstaller.h @@ -0,0 +1,160 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include +#include + +class PackProfile; +class LoaderVersionListProxy; + +/* + * QML-facing bridge to installing a mod loader (Forge/NeoForge/Fabric/ + * Quilt/LiteLoader) into one instance's PackProfile - the widget-free + * replacement for InstallLoaderDialog + LoaderVersionPage. + * + * Picking a loader (selectLoader()) points `versions` at a LoaderVersionListProxy + * (models/NewInstanceController.h) - the very proxy the "New instance" + * dialog's own loader picker already uses, filtered to builds this + * instance's Minecraft version can actually run and carrying its own + * `loading`/`error`/`count` state (see VersionListLoadingProxy), so this + * class does not need to track any of that itself. Loading is lazy: + * nothing downloads until a loader is actually selected (mirrors + * LoaderVersionPage::openedImpl()'s own "fetching them all would stall the + * window on lists nobody will scroll" reasoning), and re-selecting an + * already-loaded loader does not re-download - see + * VersionListLoadingProxy::startLoadIfNeeded(). + * + * install() collapses InstallLoaderDialog's per-conflict question chain + * (settleConflicts()) into one automatic decision, the same simplification + * ContentBrowser::install() already makes for content installs: an enabled, + * non-custom conflicting loader is turned off (never removed - the least + * destructive option the widget dialog itself offers), so a plain "Install" + * click cannot delete anything. `conflictName` tells QML which loader (if + * any) that will be, so the tab can confirm through the app's own + * ConfirmDialog before calling install() - this class does not ask itself, + * since it has no UI to ask with. + * + * Created lazily by InstanceDetails::loaderInstaller() and parented to it, + * so opening the Version tab never touches the network by itself. + */ +class LoaderInstaller : public QObject +{ + Q_OBJECT + + /// Every loader this launcher knows how to install, in display order - + /// see knownModLoaders(). Each entry is {uid, brandName, iconName}. + Q_PROPERTY(QVariantList loaders READ loaders CONSTANT) + /// The uid last passed to selectLoader(), or empty before the first + /// call. + Q_PROPERTY(QString selectedUid READ selectedUid NOTIFY selectedUidChanged) + /// The selected loader's version list, filtered to this instance's + /// Minecraft version - see the class comment. Its source model is null + /// (so it lists nothing) before the first selectLoader() call, or when + /// `supported` is false. + Q_PROPERTY(QObject* versions READ versions NOTIFY selectedUidChanged) + /// False when the selected loader is known ahead of time (from its + /// stated `earliestMinecraft`) not to run on this instance's Minecraft + /// version - `versions` is not even worth loading then. + Q_PROPERTY(bool supported READ supported NOTIFY selectedUidChanged) + /// User-facing reason for `supported` being false; empty otherwise. + Q_PROPERTY( + QString unsupportedReason READ unsupportedReason NOTIFY selectedUidChanged) + /// The selected loader's currently installed version, or empty if it + /// is not installed at all. + Q_PROPERTY( + QString installedVersion READ installedVersion NOTIFY selectedUidChanged) + /// The display name of an already-installed, switched-on loader that + /// conflicts with the selected one, or empty if there is none - see + /// the class comment. + Q_PROPERTY(QString conflictName READ conflictName NOTIFY selectedUidChanged) + + public: + explicit LoaderInstaller(PackProfile* profile, QObject* parent = nullptr); + ~LoaderInstaller() override; + + QVariantList loaders() const; + QString selectedUid() const + { + return m_selectedUid; + } + QObject* versions() const; + bool supported() const + { + return m_supported; + } + QString unsupportedReason() const + { + return m_unsupportedReason; + } + QString installedVersion() const; + QString conflictName() const; + + /// Points this installer at loader `uid` (one of loaders()' uids), + /// loading its version list the first time it is selected - see the + /// class comment. Re-picking the same uid re-applies the current + /// Minecraft version filter but does not redownload. + Q_INVOKABLE void selectLoader(const QString& uid); + /// Installs `versionId` of the selected loader into the profile: turns + /// off one conflicting loader if there is one (see conflictName() and + /// the class comment), sets the component's version, switches it back + /// on if it was previously installed and disabled, and resolves. + /// Returns false (the profile's own lastError has why) if nothing is + /// selected, `versionId` is empty, or the profile is already busy with + /// another update. + Q_INVOKABLE bool install(const QString& versionId); + + /// The three kinds of step install() can perform, in the order a + /// single call may need them. + enum class InstallStep { DisableConflict, EnableSelected, ChangeVersion }; + + /// The fixed order install() performs its side effects in, given how + /// many already-enabled, non-custom conflicting loaders it found (see + /// conflictName()'s own filter): every conflict is disabled first, + /// then the selected loader is switched on, and only then is its + /// version changed (which is what triggers PackProfile::resolve()). + /// That last ordering matters: Component::applyTo() skips disabled + /// components, so resolving while the selected loader is still + /// disabled would silently drop it from the launch profile until an + /// unrelated resolve happened later - see install()'s own comment. + /// Pulled out as a pure, static function so this sequencing is + /// unit-testable without a real PackProfile/MinecraftInstance + /// fixture, the same reason ContentBrowser::isVersionCompatible() is + /// public and static. + static QList installSequence(int conflictCount); + + signals: + void selectedUidChanged(); + + private: + PackProfile* m_profile; + QString m_selectedUid; + bool m_supported = true; + QString m_unsupportedReason; + /// Re-pointed at a different loader's Meta::VersionList on each + /// selectLoader() call - mirrors + /// NewInstanceController::refreshLoaderSource(). One proxy is enough + /// (unlike ContentBrowser's per-provider models): only one loader is + /// ever being looked at at a time. + std::unique_ptr m_versions; +}; diff --git a/launcher/models/LoaderInstaller_test.cpp b/launcher/models/LoaderInstaller_test.cpp new file mode 100644 index 00000000..26478b0c --- /dev/null +++ b/launcher/models/LoaderInstaller_test.cpp @@ -0,0 +1,77 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include + +#include "models/LoaderInstaller.h" + +/* installSequence() is public and static, and touches neither a + * PackProfile nor a MinecraftInstance - see its own comment and + * install()'s. Constructing a real PackProfile (which needs a + * MinecraftInstance) to exercise install() itself is impractical here, + * the same reason ContentBrowser_test.cpp only exercises + * ContentBrowser's static helpers - installSequence() exists so the one + * thing actually worth testing (the order install() performs its side + * effects in) can be, without that fixture. */ +class LoaderInstallerTest : public QObject +{ + Q_OBJECT + + private slots: + + void test_NoConflictsEnablesBeforeChangingVersion() + { + using Step = LoaderInstaller::InstallStep; + const QList steps = LoaderInstaller::installSequence(0); + + QCOMPARE(steps.size(), 2); + QVERIFY(steps.at(0) == Step::EnableSelected); + QVERIFY(steps.at(1) == Step::ChangeVersion); + } + + void test_ConflictsAreDisabledBeforeEnablingOrChangingVersion() + { + using Step = LoaderInstaller::InstallStep; + const QList steps = LoaderInstaller::installSequence(2); + + QCOMPARE(steps.size(), 4); + QVERIFY(steps.at(0) == Step::DisableConflict); + QVERIFY(steps.at(1) == Step::DisableConflict); + QVERIFY(steps.at(2) == Step::EnableSelected); + QVERIFY(steps.at(3) == Step::ChangeVersion); + } + + /* The bug this review caught: EnableSelected has to happen before + * ChangeVersion (which triggers PackProfile::resolve()), whatever + * else is going on - see the class comment. */ + void test_EnableAlwaysPrecedesChangeVersion() + { + using Step = LoaderInstaller::InstallStep; + for (int conflictCount = 0; conflictCount < 4; ++conflictCount) { + const QList steps = + LoaderInstaller::installSequence(conflictCount); + QVERIFY(steps.indexOf(Step::EnableSelected) < + steps.indexOf(Step::ChangeVersion)); + } + } +}; + +QTEST_GUILESS_MAIN(LoaderInstallerTest) + +#include "LoaderInstaller_test.moc" diff --git a/launcher/models/ManagedPackController.cpp b/launcher/models/ManagedPackController.cpp new file mode 100644 index 00000000..fc84e577 --- /dev/null +++ b/launcher/models/ManagedPackController.cpp @@ -0,0 +1,355 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "ManagedPackController.h" + +#include +#include +#include +#include + +#include "BaseInstance.h" +#include "InstanceImportTask.h" +#include "InstanceList.h" +#include "core/LauncherContext.h" +#include "modplatform/flame/FlameApi.h" +#include "modplatform/modrinth/ModrinthApi.h" +#include "net/Download.h" +#include "settings/SettingsObject.h" +#include "tasks/Task.h" +#include "tasks/TaskWatcher.h" + +ManagedPackController::Provider +ManagedPackController::providerFromString(const QString& provider) +{ + const QString normalised = provider.trimmed().toLower(); + if (normalised == QLatin1String("modrinth")) { + return Provider::Modrinth; + } + // "flame" is what the upstream launchers call CurseForge - see + // ManagedPackPage::providerFromString()'s own comment. + if (normalised == QLatin1String("curseforge") || + normalised == QLatin1String("flame")) { + return Provider::CurseForge; + } + return Provider::Unknown; +} + +bool ManagedPackController::isSupported(const BaseInstance* instance) +{ + if (instance == nullptr || !instance->isManagedPack()) { + return false; + } + const Provider provider = + providerFromString(instance->managedPackProvider()); + if (provider == Provider::Unknown) { + return false; + } + if (provider == Provider::CurseForge && + LAUNCHER->settings()->get("CurseForgeAPIKey").toString().isEmpty()) { + return false; + } + return true; +} + +ManagedPackController::ManagedPackController(BaseInstance* instance, + QObject* parent) + : QObject(parent), m_instance(instance) +{ + m_provider = providerFromString(m_instance->managedPackProvider()); +} + +ManagedPackController::~ManagedPackController() +{ + if (m_versionsJob) { + m_versionsJob->abort(); + } +} + +QString ManagedPackController::providerLabel() const +{ + switch (m_provider) { + case Provider::Modrinth: + return QStringLiteral("Modrinth"); + case Provider::CurseForge: + return QStringLiteral("CurseForge"); + case Provider::Unknown: + break; + } + return QString(); +} + +QString ManagedPackController::packName() const +{ + return m_instance->managedPackName(); +} + +QString ManagedPackController::packId() const +{ + return m_instance->managedPackId(); +} + +QString ManagedPackController::installedVersionName() const +{ + return m_instance->managedPackVersionName(); +} + +QString ManagedPackController::installedVersionId() const +{ + return m_instance->managedPackVersionId(); +} + +bool ManagedPackController::hasPackId() const +{ + return m_instance->hasManagedPackId(); +} + +QString ManagedPackController::packUrl() const +{ + const QString packId = m_instance->managedPackId(); + switch (m_provider) { + case Provider::Modrinth: { + // Modrinth accepts either the slug or the id here; the slug is + // preferred (it is what the user would see in a browser) but + // not always recorded. + const QString slug = m_instance->managedPackSlug(); + return ModrinthApi::get() + .projectPageUrl(slug.isEmpty() ? packId : slug) + .toString(); + } + case Provider::CurseForge: + return FlameApi::get().projectPageUrl(packId).toString(); + case Provider::Unknown: + break; + } + return m_instance->managedPackSourceUrl(); +} + +void ManagedPackController::setLoading(bool loading) +{ + if (m_loading == loading) { + return; + } + m_loading = loading; + emit loadingChanged(); +} + +void ManagedPackController::setError(const QString& error) +{ + m_error = error; + emit errorChanged(); +} + +void ManagedPackController::fetchVersions() +{ + if (m_loaded || m_loading || !hasPackId()) { + return; + } + + const QString packId = m_instance->managedPackId(); + QUrl url; + switch (m_provider) { + case Provider::Modrinth: { + ModPlatform::VersionQuery query; + query.projectId = packId; + url = ModrinthApi::get().projectVersionsUrl(query); + break; + } + case Provider::CurseForge: { + ModPlatform::VersionQuery query; + query.projectId = packId; + url = FlameApi::get().projectVersionsUrl(query); + break; + } + case Provider::Unknown: + setError(tr("Unknown pack provider.")); + return; + } + + setLoading(true); + setError(QString()); + + auto response = std::make_shared(); + const quint64 generation = ++m_generation; + + m_versionsJob.reset(new NetJob(QStringLiteral("ManagedPack::Versions(%1)") + .arg(packId), + LAUNCHER->network())); + m_versionsJob->addNetAction( + Net::Download::makeByteArray(url, response.get())); + + connect(m_versionsJob.get(), &NetJob::succeeded, this, + [this, response, generation] { + applyVersions(generation, *response); + }); + connect(m_versionsJob.get(), &NetJob::failed, this, + [this, generation](const QString& reason) { + if (generation != m_generation) { + return; + } + setLoading(false); + setError(reason); + }); + + m_versionsJob->start(); +} + +void ManagedPackController::reload() +{ + m_loaded = false; + m_versions.clear(); + m_versionsVariant.clear(); + emit versionsChanged(); + fetchVersions(); +} + +void ManagedPackController::applyVersions(quint64 generation, + const QByteArray& bytes) +{ + if (generation != m_generation) { + return; + } + setLoading(false); + + bool parsed = false; + switch (m_provider) { + case Provider::Modrinth: + m_versions = ManagedPack::parseModrinthVersions(bytes, &parsed); + break; + case Provider::CurseForge: + m_versions = ManagedPack::parseCurseForgeFiles(bytes, &parsed); + break; + case Provider::Unknown: + break; + } + + if (!parsed || m_versions.isEmpty()) { + setError(tr("Failed to read the available versions.")); + return; + } + + if (m_provider == Provider::CurseForge) { + // CurseForge returns a null downloadUrl for a file whose project + // opted out of third-party distribution - the common case for + // modpacks, not the exception. The site's own download route + // serves those files (see ManagedPackPage::applyVersions()'s own + // comment), so fall back to it here too. + const QString packId = m_instance->managedPackId(); + for (auto& version : m_versions) { + if (version.downloadUrl.isEmpty()) { + version.downloadUrl = + FlameApi::browserDownloadUrl(packId, version.versionId); + } + } + } + + m_loaded = true; + rebuildVariants(); +} + +void ManagedPackController::rebuildVariants() +{ + const QString installedId = m_instance->managedPackVersionId(); + const QString installedName = m_instance->managedPackVersionName(); + + m_versionsVariant.clear(); + for (const auto& version : m_versions) { + const bool isInstalled = + (!installedId.isEmpty() && version.versionId == installedId) || + (installedId.isEmpty() && !installedName.isEmpty() && + (version.versionNumber == installedName || + version.displayName == installedName)); + + QVariantMap row; + row[QStringLiteral("id")] = version.versionId; + row[QStringLiteral("label")] = version.label(); + row[QStringLiteral("current")] = isInstalled; + row[QStringLiteral("installable")] = version.isInstallable(); + // Only ever non-empty for Modrinth (included in the version list + // reply) - CurseForge's changelog needs a second request per file, + // which this controller does not make; see the class comment. + row[QStringLiteral("changelog")] = version.changelog; + m_versionsVariant.append(row); + } + emit versionsChanged(); +} + +QObject* ManagedPackController::updateToVersion(int index) +{ + if (!hasPackId() || index < 0 || index >= m_versions.size() || + m_instance->isRunning()) { + return nullptr; + } + const ManagedPack::Version& version = m_versions.at(index); + if (!version.isInstallable()) { + return nullptr; + } + + const QUrl downloadUrl(version.downloadUrl); + const QString versionId = version.versionId; + const QString versionName = version.versionNumber.isEmpty() + ? version.displayName + : version.versionNumber; + + auto* task = new InstanceImportTask(downloadUrl); + + InstanceImportTask::UpdateTarget target; + target.instanceId = m_instance->id(); + target.versionId = versionId; + target.versionLabel = versionName; + task->setUpdateTarget(target); + // Straight out of the catalogue's own version list - the launcher + // chose this download, not the user, so it is trusted the same way + // ManagedPackPage::update() trusts a catalogue selection. + task->setTrustedSource(true); + + InstanceImportTask::PackSourceHint hint; + hint.provider = m_instance->managedPackProvider(); + hint.packId = m_instance->managedPackId(); + hint.packSlug = m_instance->managedPackSlug(); + hint.packName = m_instance->managedPackName(); + hint.sourceUrl = m_instance->managedPackSourceUrl(); + hint.versionId = versionId; + hint.versionLabel = versionName; + task->setPackSourceHint(hint); + + task->setGroup(LAUNCHER->instances()->getInstanceGroup(m_instance->id())); + task->setIcon(m_instance->iconKey()); + // Kept exactly as it is: ManagedPackPage offers to fold the new version + // into the name too, but only after asking (CustomMessageBox), which + // this bridge has no dialog to do. Leaving the name alone is the safe + // default the same comment there argues for either way. + task->setName(m_instance->name()); + + Task* wrapped = LAUNCHER->instances()->wrapInstanceTask(task); + auto* watcher = new TaskWatcher(Task::Ptr(wrapped), this); + watcher->setTitle(versionName.isEmpty() ? packName() : versionName); + watcher->setInstanceId(m_instance->id()); + /* Deliberately not refreshed/reloaded from here on success: this + * instance has just been replaced on disk (staged over its own id), + * and m_instance is a bare pointer borrowed from the InstanceDetails + * that owns this controller - continuing to read it, or asking it for + * a new version list, risks doing so against a stale object. The + * widget page dealt with the same fact by closing its own window; the + * QML tab does the equivalent by navigating back to the library once + * this watcher succeeds, rather than this controller trying to refresh + * itself in place. */ + wrapped->start(); + return watcher; +} diff --git a/launcher/models/ManagedPackController.h b/launcher/models/ManagedPackController.h new file mode 100644 index 00000000..1c8bbb81 --- /dev/null +++ b/launcher/models/ManagedPackController.h @@ -0,0 +1,148 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include + +#include "modplatform/ManagedPackVersions.h" +#include "net/NetJob.h" + +class BaseInstance; + +/* + * QML-facing view of one instance's Modrinth/CurseForge provenance and + * available updates - the widget-free replacement for ManagedPackPage, + * scoped to instances that have a catalogue id (hasManagedPackId()). + * + * The no-pack-id mode ManagedPackPage also offers (update from a hand-typed + * URL or a local file) is deliberately not reproduced here: that mode + * exists for instances an older MeshMC, or a drag-and-drop import, recorded + * without ever storing a catalogue id, and covering it would mean adding a + * QML file picker for an import path this pass has not had time to verify + * end to end. hasPackId is false for that case so a QML tab can say so + * rather than pretend the feature works. + * + * Created lazily by InstanceDetails::managedPack() - see that method - and + * only when isSupported() actually says yes, mirroring ManagedPackPage:: + * isSupported() exactly (a provider MeshMC recognises, and a CurseForge + * build actually compiled with an API key). + */ +class ManagedPackController : public QObject +{ + Q_OBJECT + + Q_PROPERTY(QString providerLabel READ providerLabel CONSTANT) + Q_PROPERTY(QString packName READ packName CONSTANT) + Q_PROPERTY(QString packUrl READ packUrl CONSTANT) + Q_PROPERTY(QString packId READ packId CONSTANT) + Q_PROPERTY(QString installedVersionName READ installedVersionName CONSTANT) + Q_PROPERTY(QString installedVersionId READ installedVersionId CONSTANT) + /// False for a pack MeshMC knows the provider of but not the catalogue + /// id of (an old import) - fetchVersions()/updateToVersion() are both + /// no-ops while this is false. See the class comment. + Q_PROPERTY(bool hasPackId READ hasPackId CONSTANT) + /// QVariantList of {id, label, current, installable, changelog}, newest + /// first - empty until fetchVersions() succeeds. + Q_PROPERTY(QVariantList versions READ versions NOTIFY versionsChanged) + Q_PROPERTY(bool loading READ loading NOTIFY loadingChanged) + /// Empty on success, or while nothing has failed yet. + Q_PROPERTY(QString error READ error NOTIFY errorChanged) + + public: + explicit ManagedPackController(BaseInstance* instance, + QObject* parent = nullptr); + ~ManagedPackController() override; + + /// Whether an instance page should offer this at all - mirrors + /// ManagedPackPage::isSupported() exactly (see its own comment): + /// a provider MeshMC recognises, and - for CurseForge - a build that + /// actually has an API key compiled in. + static bool isSupported(const BaseInstance* instance); + + enum class Provider { Unknown, Modrinth, CurseForge }; + /// Maps the `PackProvider` string onto the enum - mirrors + /// ManagedPackPage::providerFromString() exactly (see its own comment + /// on "flame" as a CurseForge synonym). Public, unlike the rest of this + /// class's internals, so it can be unit tested without a BaseInstance - + /// same reasoning as ContentBrowser::isVersionCompatible(). + static Provider providerFromString(const QString& provider); + + QString providerLabel() const; + QString packName() const; + QString packUrl() const; + QString packId() const; + QString installedVersionName() const; + QString installedVersionId() const; + bool hasPackId() const; + QVariantList versions() const + { + return m_versionsVariant; + } + bool loading() const + { + return m_loading; + } + QString error() const + { + return m_error; + } + + /// Fetches the pack's version list. A no-op once already loaded (or + /// loading) - see reload() to force a refresh. + Q_INVOKABLE void fetchVersions(); + /// Drops whatever was loaded and fetches again - for a "Reload" action + /// after a failure. + Q_INVOKABLE void reload(); + /// Replaces this instance with version `versions()[index]` in place, + /// the same InstanceImportTask-based update ManagedPackPage:: + /// updatePack() runs for a catalogue-selected version (trusted source, + /// since the download URL came from the catalogue itself). Returns a + /// TaskWatcher, or null when `index` is out of range, that version has + /// no download (CurseForge withholds some), or the instance is + /// currently running. + Q_INVOKABLE QObject* updateToVersion(int index); + + signals: + void versionsChanged(); + void loadingChanged(); + void errorChanged(); + + private: + void setLoading(bool loading); + void setError(const QString& error); + void applyVersions(quint64 generation, const QByteArray& bytes); + void rebuildVariants(); + + /// Borrowed - valid for this controller's whole lifetime (parented to + /// the InstanceDetails that owns both). + BaseInstance* m_instance; + Provider m_provider = Provider::Unknown; + + ManagedPack::VersionList m_versions; + QVariantList m_versionsVariant; + bool m_loaded = false; + bool m_loading = false; + QString m_error; + + NetJob::Ptr m_versionsJob; + quint64 m_generation = 0; +}; diff --git a/launcher/models/ManagedPackController_test.cpp b/launcher/models/ManagedPackController_test.cpp new file mode 100644 index 00000000..eb918506 --- /dev/null +++ b/launcher/models/ManagedPackController_test.cpp @@ -0,0 +1,62 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include + +#include "models/ManagedPackController.h" + +/* Exercises only providerFromString() - the one static, instance-free + * helper this class has. isSupported()/fetchVersions()/updateToVersion() + * all need a real BaseInstance (and, for the CurseForge branch, a + * LauncherContext to read the API key setting from), which is impractical + * to construct here - the same reason models/ContentBrowser_test.cpp only + * exercises ContentBrowser's own static helpers. */ +class ManagedPackControllerTest : public QObject +{ + Q_OBJECT + + private slots: + void providerFromString(); +}; + +void ManagedPackControllerTest::providerFromString() +{ + using Provider = ManagedPackController::Provider; + + QCOMPARE(ManagedPackController::providerFromString("modrinth"), + Provider::Modrinth); + QCOMPARE(ManagedPackController::providerFromString("MODRINTH"), + Provider::Modrinth); + QCOMPARE(ManagedPackController::providerFromString(" modrinth "), + Provider::Modrinth); + QCOMPARE(ManagedPackController::providerFromString("curseforge"), + Provider::CurseForge); + // "flame" is what the upstream launchers call CurseForge. + QCOMPARE(ManagedPackController::providerFromString("flame"), + Provider::CurseForge); + QCOMPARE(ManagedPackController::providerFromString("Flame"), + Provider::CurseForge); + QCOMPARE(ManagedPackController::providerFromString(""), + Provider::Unknown); + QCOMPARE(ManagedPackController::providerFromString("technic"), + Provider::Unknown); +} + +QTEST_GUILESS_MAIN(ManagedPackControllerTest) +#include "ManagedPackController_test.moc" diff --git a/launcher/models/NewInstanceController.cpp b/launcher/models/NewInstanceController.cpp new file mode 100644 index 00000000..72981d08 --- /dev/null +++ b/launcher/models/NewInstanceController.cpp @@ -0,0 +1,565 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "NewInstanceController.h" + +#include +#include + +#include "BaseVersion.h" +#include "BaseVersionList.h" +#include "InstanceCreationTask.h" +#include "InstanceImportTask.h" +#include "InstanceList.h" +#include "core/LauncherContext.h" +#include "meta/Index.h" +#include "meta/VersionList.h" +#include "minecraft/Component.h" +#include "tasks/Task.h" +#include "tasks/TaskWatcher.h" + +namespace +{ + /* First role in @p model's roleNames() named @p name, or @p fallback if + * @p model is null or names no role that way. Same helper as + * InstanceFilterModel.cpp's, duplicated rather than shared: both files + * are small and self-contained, and neither is the obvious home for a + * third caller to depend on. */ + int roleByName(const QAbstractItemModel* model, const QByteArray& name, + int fallback) + { + if (!model) { + return fallback; + } + const auto roles = model->roleNames(); + for (auto it = roles.constBegin(); it != roles.constEnd(); ++it) { + if (it.value() == name) { + return it.key(); + } + } + return fallback; + } + + /// The component uid knownModLoaders() (minecraft/Component.h) lists it + /// under, or empty for "" (no loader) or an unrecognised name. + QString loaderComponentUid(const QString& loader) + { + if (loader == QLatin1String("fabric")) { + return QStringLiteral("net.fabricmc.fabric-loader"); + } + if (loader == QLatin1String("quilt")) { + return QStringLiteral("org.quiltmc.quilt-loader"); + } + if (loader == QLatin1String("forge")) { + return QStringLiteral("net.minecraftforge"); + } + if (loader == QLatin1String("neoforge")) { + return QStringLiteral("net.neoforged"); + } + return QString(); + } +} // namespace + +// ---- VersionListLoadingProxy ----------------------------------------- + +VersionListLoadingProxy::VersionListLoadingProxy(QObject* parent) + : QSortFilterProxyModel(parent) +{ + connect(this, &QAbstractItemModel::rowsInserted, this, + &VersionListLoadingProxy::countChanged); + connect(this, &QAbstractItemModel::rowsRemoved, this, + &VersionListLoadingProxy::countChanged); + connect(this, &QAbstractItemModel::modelReset, this, + &VersionListLoadingProxy::countChanged); + connect(this, &QAbstractItemModel::layoutChanged, this, + &VersionListLoadingProxy::countChanged); + + connect(this, &QAbstractItemModel::rowsInserted, this, + &VersionListLoadingProxy::refreshDerived); + connect(this, &QAbstractItemModel::rowsRemoved, this, + &VersionListLoadingProxy::refreshDerived); + connect(this, &QAbstractItemModel::modelReset, this, + &VersionListLoadingProxy::refreshDerived); + connect(this, &QAbstractItemModel::layoutChanged, this, + &VersionListLoadingProxy::refreshDerived); +} + +QString VersionListLoadingProxy::firstVersionId() const +{ + return m_firstVersionId; +} + +void VersionListLoadingProxy::setLoading(bool loading) +{ + if (m_loading == loading) { + return; + } + m_loading = loading; + emit loadingChanged(); +} + +void VersionListLoadingProxy::setError(const QString& error) +{ + if (m_error == error) { + return; + } + m_error = error; + emit errorChanged(); +} + +void VersionListLoadingProxy::refreshDerived() +{ + const QString firstVersionId = + (rowCount() > 0 && m_versionIdRole >= 0) + ? index(0, 0).data(m_versionIdRole).toString() + : QString(); + if (firstVersionId != m_firstVersionId) { + m_firstVersionId = firstVersionId; + emit firstVersionIdChanged(); + } +} + +void VersionListLoadingProxy::startLoadIfNeeded() +{ + auto* list = qobject_cast(sourceModel()); + if (!list || list->isLoaded()) { + return; + } + + Task::Ptr task = list->getLoadTask(); + if (!task) { + return; + } + + setLoading(true); + connect(task.get(), &Task::succeeded, this, + [this]() { setLoading(false); }); + connect(task.get(), &Task::failed, this, + [this](const QString& reason) { + setLoading(false); + setError(reason); + }); + if (!task->isRunning()) { + task->start(); + } +} + +void VersionListLoadingProxy::setSourceModel(QAbstractItemModel* sourceModel) +{ + /* Roles first, same reasoning as InstanceFilterModel::setSourceModel(): + * the base class starts filtering as soon as it has a model. */ + m_versionIdRole = roleByName(sourceModel, "versionId", -1); + m_sortRole = roleByName(sourceModel, "sort", -1); + resolveRoles(sourceModel); + + QSortFilterProxyModel::setSourceModel(sourceModel); + + setLoading(false); + setError(QString()); + + /* A QSortFilterProxyModel does not sort until something asks it to. */ + sort(0); + refreshDerived(); + startLoadIfNeeded(); +} + +bool VersionListLoadingProxy::lessThan(const QModelIndex& left, + const QModelIndex& right) const +{ + if (m_sortRole >= 0) { + /* Newest first. */ + return left.data(m_sortRole).toLongLong() > + right.data(m_sortRole).toLongLong(); + } + /* No sort role to compare on - keep source order. Meta::VersionList + * already sorts its own rows newest-first (VersionList::setVersions(), + * meta/VersionList.cpp), so this still does the right thing for it; + * a fake list in a test controls the order it wants directly. */ + return left.row() < right.row(); +} + +// ---- MinecraftVersionListProxy ----------------------------------------- + +MinecraftVersionListProxy::MinecraftVersionListProxy(QObject* parent) + : VersionListLoadingProxy(parent) +{ +} + +void MinecraftVersionListProxy::setShowSnapshots(bool show) +{ + if (m_showSnapshots == show) { + return; + } + m_showSnapshots = show; + emit showSnapshotsChanged(); + invalidateFilter(); +} + +void MinecraftVersionListProxy::setShowOldVersions(bool show) +{ + if (m_showOldVersions == show) { + return; + } + m_showOldVersions = show; + emit showOldVersionsChanged(); + invalidateFilter(); +} + +void MinecraftVersionListProxy::resolveRoles(QAbstractItemModel* sourceModel) +{ + m_typeRole = roleByName(sourceModel, "type", -1); +} + +bool MinecraftVersionListProxy::filterAcceptsRow( + int sourceRow, const QModelIndex& sourceParent) const +{ + if (m_typeRole < 0) { + /* Nothing to filter on - show everything rather than nothing. */ + return true; + } + const QString type = + sourceModel()->index(sourceRow, 0, sourceParent).data(m_typeRole).toString(); + if (type == QLatin1String("release")) { + return true; + } + if (type == QLatin1String("snapshot")) { + return m_showSnapshots; + } + if (type == QLatin1String("old_alpha") || type == QLatin1String("old_beta") || + type == QLatin1String("old_snapshot")) { + return m_showOldVersions; + } + /* Anything else (e.g. an "experiment" build) - VanillaPage has a + * separate checkbox for that; nothing here does, so it stays hidden. */ + return false; +} + +// ---- LoaderVersionListProxy ----------------------------------------- + +LoaderVersionListProxy::LoaderVersionListProxy(QObject* parent) + : VersionListLoadingProxy(parent) +{ +} + +void LoaderVersionListProxy::setMinecraftVersion(const QString& version) +{ + if (m_minecraftVersion == version) { + return; + } + m_minecraftVersion = version; + invalidateFilter(); +} + +void LoaderVersionListProxy::resolveRoles(QAbstractItemModel* sourceModel) +{ + m_parentVersionRole = roleByName(sourceModel, "parentGameVersion", -1); +} + +bool LoaderVersionListProxy::filterAcceptsRow( + int sourceRow, const QModelIndex& sourceParent) const +{ + if (m_parentVersionRole < 0 || m_minecraftVersion.isEmpty()) { + return true; + } + const QString parent = sourceModel() + ->index(sourceRow, 0, sourceParent) + .data(m_parentVersionRole) + .toString(); + /* "Exact if present" - see the class comment in the header. */ + return parent.isEmpty() || parent == m_minecraftVersion; +} + +// ---- composeSuggestedInstanceName ----------------------------------------- + +QString composeSuggestedInstanceName(const QString& minecraftVersion, + const QString& loader) +{ + if (minecraftVersion.isEmpty()) { + return QString(); + } + QString name = minecraftVersion; + if (const ModLoaderInfo* info = modLoaderForUid(loaderComponentUid(loader))) { + name += QLatin1Char(' ') + info->brandName; + } + return name; +} + +// ---- suggestedImportName ----------------------------------------- + +QString suggestedImportName(const QString& source) +{ + QString input = source.trimmed(); + if (input.isEmpty()) { + return QString(); + } + + QUrl url = QUrl::fromUserInput(input); + if (url.isLocalFile()) { + return QFileInfo(url.toLocalFile()).completeBaseName(); + } + + /* CurseForge's own "download" button links end this way; the real + * file name sits one path segment further in, at ".../file". Same + * rewrite ImportPage::updateState() applies before reading the file + * name (ui/pages/modplatform/ImportPage.cpp). */ + if (input.endsWith(QLatin1String("?client=y"))) { + input.chop(9); + input.append(QLatin1String("/file")); + url = QUrl::fromUserInput(input); + } + return QFileInfo(url.fileName()).completeBaseName(); +} + +// ---- importSourceLooksValid ----------------------------------------- + +bool importSourceLooksValid(const QString& source) +{ + const QString trimmed = source.trimmed(); + if (trimmed.isEmpty()) { + return false; + } + + const QUrl url = QUrl::fromUserInput(trimmed); + if (!url.isValid() || url.isEmpty()) { + return false; + } + if (!url.isLocalFile()) { + /* A remote link - InstanceImportTask is the only thing that can + * actually tell whether it resolves to something importable. */ + return true; + } + + /* Same extension allow-list ImportPage::updateState() checks + * (ui/pages/modplatform/ImportPage.cpp); the real format sniff happens + * inside InstanceImportTask itself. */ + const QFileInfo fi(url.toLocalFile()); + const QString suffix = fi.suffix().toLower(); + const bool looksLikeArchive = suffix == QLatin1String("zip") || + suffix == QLatin1String("mrpack") || + suffix == QLatin1String("jar"); + return fi.exists() && looksLikeArchive; +} + +// ---- NewInstanceController ----------------------------------------- + +NewInstanceController::NewInstanceController(QObject* parent) + : QObject(parent), + m_minecraftVersions(std::make_unique()), + m_loaderVersions(std::make_unique()) +{ + auto mcList = + LAUNCHER->metadataIndex()->get(QStringLiteral("net.minecraft")); + m_minecraftVersions->setSourceModel(mcList.get()); + + connect(m_loaderVersions.get(), &VersionListLoadingProxy::loadingChanged, + this, &NewInstanceController::loaderLoadingChanged); + connect(m_loaderVersions.get(), + &VersionListLoadingProxy::firstVersionIdChanged, this, + &NewInstanceController::onLoaderListFirstVersionIdChanged); +} + +QObject* NewInstanceController::minecraftVersions() const +{ + return m_minecraftVersions.get(); +} + +QObject* NewInstanceController::loaderVersions() const +{ + return m_loaderVersions.get(); +} + +bool NewInstanceController::loaderLoading() const +{ + return m_loaderVersions->loading(); +} + +QStringList NewInstanceController::groups() const +{ + /* Same cleanup NewInstanceDialog's constructor applies to + * InstanceList::getGroups() before handing it to its combo box + * (ui/dialogs/NewInstanceDialog.cpp), minus the initialGroup bias - + * this API takes the target group as a create() argument instead. */ + auto groups = LAUNCHER->instances()->getGroups(); + groups.removeDuplicates(); + groups.sort(Qt::CaseInsensitive); + groups.removeOne(QString()); + groups.prepend(QString()); + return groups; +} + +void NewInstanceController::setLoader(const QString& loader) +{ + if (m_loader == loader) { + return; + } + m_loader = loader; + emit loaderChanged(); + + if (!m_selectedLoaderVersion.isEmpty()) { + m_selectedLoaderVersion.clear(); + emit selectedLoaderVersionChanged(); + } + + refreshLoaderSource(); + // A list that is already loaded never announces a "new" first version. + onLoaderListFirstVersionIdChanged(); +} + +void NewInstanceController::refreshLoaderSource() +{ + const QString uid = loaderComponentUid(m_loader); + if (uid.isEmpty()) { + m_loaderVersions->setSourceModel(nullptr); + return; + } + + auto list = LAUNCHER->metadataIndex()->get(uid); + m_loaderVersions->setMinecraftVersion(m_selectedMinecraftVersion); + m_loaderVersions->setSourceModel(list.get()); +} + +void NewInstanceController::selectMinecraftVersion(const QString& version) +{ + if (m_selectedMinecraftVersion == version) { + return; + } + m_selectedMinecraftVersion = version; + emit selectedMinecraftVersionChanged(); + + /* A loader version picked for the previous Minecraft version may not + * even exist for this one (Forge and NeoForge publish one build per + * game version) - drop it and let onLoaderListFirstVersionIdChanged() + * default to whatever fits the new one. */ + if (!m_selectedLoaderVersion.isEmpty()) { + m_selectedLoaderVersion.clear(); + emit selectedLoaderVersionChanged(); + } + + m_loaderVersions->setMinecraftVersion(version); + /* Same list, same first version: nothing would re-default the pick + * cleared above, so do it now. */ + onLoaderListFirstVersionIdChanged(); +} + +void NewInstanceController::selectLoaderVersion(const QString& version) +{ + if (m_selectedLoaderVersion == version) { + return; + } + m_selectedLoaderVersion = version; + emit selectedLoaderVersionChanged(); +} + +void NewInstanceController::onLoaderListFirstVersionIdChanged() +{ + if (!m_selectedLoaderVersion.isEmpty()) { + /* selectLoaderVersion() already named one - do not second-guess it. */ + return; + } + const QString first = m_loaderVersions->firstVersionId(); + if (first.isEmpty()) { + return; + } + m_selectedLoaderVersion = first; + emit selectedLoaderVersionChanged(); +} + +QString NewInstanceController::suggestedName() const +{ + return composeSuggestedInstanceName(m_selectedMinecraftVersion, m_loader); +} + +QObject* NewInstanceController::create(const QString& name, + const QString& group, + const QString& iconKey) +{ + if (m_selectedMinecraftVersion.isEmpty()) { + return nullptr; + } + + auto mcList = + LAUNCHER->metadataIndex()->get(QStringLiteral("net.minecraft")); + BaseVersionPtr version = + mcList ? mcList->findVersion(m_selectedMinecraftVersion) : nullptr; + if (!version) { + return nullptr; + } + + QString loaderUid; + QString loaderVersion; + if (!m_loader.isEmpty() && !m_selectedLoaderVersion.isEmpty()) { + loaderUid = loaderComponentUid(m_loader); + loaderVersion = m_selectedLoaderVersion; + } + + auto* creationTask = + new InstanceCreationTask(version, loaderUid, loaderVersion); + creationTask->setName(name); + creationTask->setGroup(group); + creationTask->setIcon(iconKey); + creationTask->setTargetDir(LAUNCHER->instances()->primaryDir()); + + Task* wrapped = LAUNCHER->instances()->wrapInstanceTask(creationTask); + auto* watcher = new TaskWatcher(Task::Ptr(wrapped), this); + watcher->setTitle(name); + wrapped->start(); + return watcher; +} + +QString NewInstanceController::suggestedNameForImportSource( + const QString& source) const +{ + return ::suggestedImportName(source); +} + +bool NewInstanceController::isImportSourceValid(const QString& source) const +{ + return ::importSourceLooksValid(source); +} + +QObject* NewInstanceController::importFrom(const QString& source, + const QString& name, + const QString& group, + const QString& iconKey) +{ + const QString trimmed = source.trimmed(); + if (trimmed.isEmpty()) { + return nullptr; + } + + /* Same construction the widget's ImportPage::updateState() uses + * (ui/pages/modplatform/ImportPage.cpp): accepts a bare local path, a + * "file://" URL from a picker, or a typed http(s) address alike. */ + const QUrl url = QUrl::fromUserInput(trimmed); + if (!url.isValid() || url.isEmpty()) { + return nullptr; + } + + auto* importTask = new InstanceImportTask(url); + importTask->setName(name); + importTask->setGroup(group); + importTask->setIcon(iconKey); + importTask->setTargetDir(LAUNCHER->instances()->primaryDir()); + + Task* wrapped = LAUNCHER->instances()->wrapInstanceTask(importTask); + auto* watcher = new TaskWatcher(Task::Ptr(wrapped), this); + watcher->setTitle(name.isEmpty() ? trimmed : name); + wrapped->start(); + return watcher; +} diff --git a/launcher/models/NewInstanceController.h b/launcher/models/NewInstanceController.h new file mode 100644 index 00000000..5d354e38 --- /dev/null +++ b/launcher/models/NewInstanceController.h @@ -0,0 +1,351 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include + +#include + +/* + * Base for the two Meta version-list proxies below: both need the same + * loading/error tracking and the same "newest first" fallback ordering, and + * differ only in which rows they keep. QtCore only, same rule as the other + * QML-facing models here (InstanceFilterModel, SettingsAdapter, ...): no + * QtWidgets, no ui/. + * + * Roles are looked up by NAME from the source model's roleNames() rather + * than assumed to be BaseVersionList's own enum values, the same reasoning + * InstanceFilterModel.h gives for doing that with InstanceList: this then + * works against a fake version list numbering its roles differently (see + * NewInstanceController_test.cpp), not only against the real one. + */ +class VersionListLoadingProxy : public QSortFilterProxyModel +{ + Q_OBJECT + + Q_PROPERTY(bool loading READ loading NOTIFY loadingChanged) + Q_PROPERTY(QString error READ error NOTIFY errorChanged) + Q_PROPERTY(int count READ count NOTIFY countChanged) + /* The versionId of the first row after filtering and sorting - "the + * newest build that fits the current filter". Used to default a + * selection without trusting the source list's own idea of + * "recommended", which knows nothing about this proxy's filter (see + * NewInstanceController::onLoaderListFirstVersionIdChanged()). Empty + * when nothing currently matches. */ + Q_PROPERTY(QString firstVersionId READ firstVersionId NOTIFY + firstVersionIdChanged) + + public: + explicit VersionListLoadingProxy(QObject* parent = nullptr); + + bool loading() const + { + return m_loading; + } + QString error() const + { + return m_error; + } + int count() const + { + return rowCount(); + } + QString firstVersionId() const; + + void setSourceModel(QAbstractItemModel* sourceModel) override; + + signals: + void loadingChanged(); + void errorChanged(); + void countChanged(); + void firstVersionIdChanged(); + + protected: + /* Hook for a subclass to resolve whatever extra role it filters rows + * on, called after the roles this base class needs are resolved and + * before the base class model is set. @p sourceModel may be null. */ + virtual void resolveRoles(QAbstractItemModel* sourceModel) + { + Q_UNUSED(sourceModel); + } + + int versionIdRole() const + { + return m_versionIdRole; + } + + bool lessThan(const QModelIndex& left, + const QModelIndex& right) const override; + + private: + void setLoading(bool loading); + void setError(const QString& error); + /// Recomputes count/firstVersionId, emitting only what actually moved. + void refreshDerived(); + /* Starts the source list's load task if it has one and it is not + * loaded already - the same thing + * VersionSelectWidget::loadList() does for the widget's own version + * pickers (ui/widgets/VersionSelectWidget.cpp), via the same + * BaseVersionList::getLoadTask() / Meta::BaseEntity::load() API. */ + void startLoadIfNeeded(); + + int m_versionIdRole = -1; + int m_sortRole = -1; + bool m_loading = false; + QString m_error; + QString m_firstVersionId; +}; + +/* + * Meta::VersionList for "net.minecraft" (BaseVersionList; see + * meta/VersionList.h), filtered to release builds by default and sorted + * newest first - the same default VanillaPage's checkboxes start on + * (ui/pages/modplatform/VanillaPage.cpp), minus its separate "experiments" + * toggle, which nothing here exposes; an experiment build is always hidden. + */ +class MinecraftVersionListProxy : public VersionListLoadingProxy +{ + Q_OBJECT + + Q_PROPERTY(bool showSnapshots READ showSnapshots WRITE setShowSnapshots + NOTIFY showSnapshotsChanged) + /// Alpha, beta and old-snapshot builds - VanillaPage's other three + /// checkboxes, collapsed into one: nothing here needs to tell them apart. + Q_PROPERTY(bool showOldVersions READ showOldVersions WRITE + setShowOldVersions NOTIFY showOldVersionsChanged) + + public: + explicit MinecraftVersionListProxy(QObject* parent = nullptr); + + bool showSnapshots() const + { + return m_showSnapshots; + } + void setShowSnapshots(bool show); + + bool showOldVersions() const + { + return m_showOldVersions; + } + void setShowOldVersions(bool show); + + signals: + void showSnapshotsChanged(); + void showOldVersionsChanged(); + + protected: + void resolveRoles(QAbstractItemModel* sourceModel) override; + bool filterAcceptsRow(int sourceRow, + const QModelIndex& sourceParent) const override; + + private: + int m_typeRole = -1; + bool m_showSnapshots = false; + bool m_showOldVersions = false; +}; + +/* + * One loader's Meta::VersionList - whichever + * NewInstanceController::loader currently names - filtered to the builds + * that apply to one Minecraft version: "exact if present", the same rule + * LoaderVersionPage applies via VersionSelectWidget::setExactIfPresentFilter() + * (ui/dialogs/InstallLoaderDialog.cpp). A build that names no Minecraft + * version (Fabric Loader, Quilt Loader) is always kept; one that does must + * match minecraftVersion exactly. + */ +class LoaderVersionListProxy : public VersionListLoadingProxy +{ + Q_OBJECT + + public: + explicit LoaderVersionListProxy(QObject* parent = nullptr); + + QString minecraftVersion() const + { + return m_minecraftVersion; + } + /// Re-filters in place; does not reload, since it is the loader (not the + /// Minecraft version) that decides which list is the source model. + void setMinecraftVersion(const QString& version); + + protected: + void resolveRoles(QAbstractItemModel* sourceModel) override; + bool filterAcceptsRow(int sourceRow, + const QModelIndex& sourceParent) const override; + + private: + int m_parentVersionRole = -1; + QString m_minecraftVersion; +}; + +/* The Minecraft version (+ loader brand, if one is given) the way + * VanillaPage::suggestCurrent() suggests a name for the widget's vanilla-only + * flow (ui/pages/modplatform/VanillaPage.cpp), extended with the loader + * since this flow can add one at creation. Empty if @p minecraftVersion is. + * + * Free-standing so it can be unit-tested without a LauncherContext: it + * touches nothing but the loader table in minecraft/Component.h. @p loader + * is "", "fabric", "quilt", "forge" or "neoforge" - see + * NewInstanceController::loader(). */ +QString composeSuggestedInstanceName(const QString& minecraftVersion, + const QString& loader); + +/* Suggested display name for an import source: a local file path, a + * "file://" URL from a picker, or a pasted http(s) link - the same "file + * name minus extension" rule the widget's ImportPage::updateState() uses + * (ui/pages/modplatform/ImportPage.cpp), including its "?client=y" + * CurseForge download-link rewrite. Empty if @p source is empty or names + * no file name QUrl can find. + * + * Free-standing for the same reason as composeSuggestedInstanceName() + * above: testable without a LauncherContext. */ +QString suggestedImportName(const QString& source); + +/* True if @p source is something InstanceImportTask could plausibly import: + * a remote http(s)/ftp URL (existence can only be found out by trying), or + * a local path that exists and has a modpack-archive extension - the same + * gate the widget's ImportPage::updateState() applies before offering the + * pack (ui/pages/modplatform/ImportPage.cpp), so the QML Import button + * doesn't light up for a typo'd local path or a non-archive file only to + * fail later, after the task has already started. Empty or unparsable + * input is never valid. + * + * Free-standing for the same reason as suggestedImportName() above. */ +bool importSourceLooksValid(const QString& source); + +/* + * QML-facing "New instance" flow: picks a Minecraft version and, optionally, + * a mod loader and its version, and builds the same task the widget's + * NewInstanceDialog + VanillaPage build for a vanilla instance + * (ui/dialogs/NewInstanceDialog.cpp, ui/pages/modplatform/VanillaPage.cpp) + * plus the loader component if one was picked - see create(). + */ +class NewInstanceController : public QObject +{ + Q_OBJECT + + /// "net.minecraft"'s version list, release-only/newest-first by default. + Q_PROPERTY(QObject* minecraftVersions READ minecraftVersions CONSTANT) + + /// "", "fabric", "quilt", "forge" or "neoforge". + Q_PROPERTY( + QString loader READ loader WRITE setLoader NOTIFY loaderChanged) + /// The chosen loader's version list, filtered to selectedMinecraftVersion. + Q_PROPERTY(QObject* loaderVersions READ loaderVersions CONSTANT) + /// Convenience mirror of loaderVersions.loading, for QML that does not + /// need the rest of the loader version list's own state. + Q_PROPERTY(bool loaderLoading READ loaderLoading NOTIFY + loaderLoadingChanged) + + Q_PROPERTY(QString selectedMinecraftVersion READ selectedMinecraftVersion + NOTIFY selectedMinecraftVersionChanged) + /// Defaults to the newest build that fits once loaderVersions settles - + /// see onLoaderListFirstVersionIdChanged() - until selectLoaderVersion() + /// is called explicitly. + Q_PROPERTY(QString selectedLoaderVersion READ selectedLoaderVersion NOTIFY + selectedLoaderVersionChanged) + + /// Existing instance groups, sorted and deduplicated the way + /// NewInstanceDialog's constructor prepares its own combo box; "" first, + /// for "no group" (ui/dialogs/NewInstanceDialog.cpp). + Q_PROPERTY(QStringList groups READ groups CONSTANT) + + public: + explicit NewInstanceController(QObject* parent = nullptr); + + QObject* minecraftVersions() const; + + QString loader() const + { + return m_loader; + } + void setLoader(const QString& loader); + + QObject* loaderVersions() const; + bool loaderLoading() const; + + QString selectedMinecraftVersion() const + { + return m_selectedMinecraftVersion; + } + QString selectedLoaderVersion() const + { + return m_selectedLoaderVersion; + } + + QStringList groups() const; + + Q_INVOKABLE void selectMinecraftVersion(const QString& version); + Q_INVOKABLE void selectLoaderVersion(const QString& version); + /// See composeSuggestedInstanceName() above. + Q_INVOKABLE QString suggestedName() const; + /* Builds and starts the instance-creation task, wrapped the same way + * MainWindow::createInstanceFromDialog() wraps NewInstanceDialog's + * (ui/MainWindow.cpp), and returns a TaskWatcher for it (parented to + * this controller, so QML gets it as CppOwnership without needing its + * own expose() call - see QmlShell::expose()). Null if no Minecraft + * version is selected yet, or if selectedMinecraftVersion names no + * version the metadata index actually has. */ + Q_INVOKABLE QObject* create(const QString& name, const QString& group, + const QString& iconKey); + + /// Wraps suggestedImportName() above for QML - see its comment. + Q_INVOKABLE QString suggestedNameForImportSource(const QString& source) const; + /// Wraps importSourceLooksValid() above for QML - see its comment. + Q_INVOKABLE bool isImportSourceValid(const QString& source) const; + /* Builds and starts an import of a local archive/export or a modpack + * download URL - the same InstanceImportTask the widget's ImportPage + + * NewInstanceDialog build (ui/pages/modplatform/ImportPage.cpp, + * ui/dialogs/NewInstanceDialog.cpp) - and returns a TaskWatcher for + * it, same CppOwnership reasoning as create() above (parented to this + * controller, so QML gets it as CppOwnership without needing its own + * expose() call). Any question the import needs to ask (blocked or + * untrusted mods, replacing an existing pack) goes through + * LAUNCHER->uiHost(), already wired for both UIs - see + * InstanceImportTask.cpp. Null if @p source is empty or + * QUrl::fromUserInput() cannot make sense of it. */ + Q_INVOKABLE QObject* importFrom(const QString& source, const QString& name, + const QString& group, + const QString& iconKey); + + signals: + void loaderChanged(); + void loaderLoadingChanged(); + void selectedMinecraftVersionChanged(); + void selectedLoaderVersionChanged(); + + private: + /// Points loaderVersions at m_loader's list (or clears it) and re-applies + /// the current selectedMinecraftVersion filter. + void refreshLoaderSource(); + /// Defaults selectedLoaderVersion once loaderVersions settles, unless + /// selectLoaderVersion() was already called for this loader/MC version. + void onLoaderListFirstVersionIdChanged(); + + std::unique_ptr m_minecraftVersions; + std::unique_ptr m_loaderVersions; + + QString m_loader; + QString m_selectedMinecraftVersion; + QString m_selectedLoaderVersion; +}; diff --git a/launcher/models/NewInstanceController_test.cpp b/launcher/models/NewInstanceController_test.cpp new file mode 100644 index 00000000..b4953195 --- /dev/null +++ b/launcher/models/NewInstanceController_test.cpp @@ -0,0 +1,358 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include +#include +#include + +#include "BaseVersionList.h" +#include "models/NewInstanceController.h" +#include "tasks/Task.h" + +namespace +{ + /* Stand-in for Meta::VersionList: exposes the same role NAMES (version- + * Id, type, parentGameVersion, sort) but deliberately different role + * NUMBERS. A test that passes against this only passes because the + * proxies resolve roles by name, not because they happen to reuse + * Meta::VersionList's own numbers - same reasoning as + * InstanceFilterModel_test.cpp's FakeInstanceModel. */ + class FakeVersionList : public BaseVersionList + { + public: + struct Row { + QString versionId; + QString type; + QString parentGameVersion; + qint64 sort = 0; + }; + + enum Roles { + VersionIdRole = Qt::UserRole + 40, + TypeRole, + ParentVersionRole, + SortRole + }; + + explicit FakeVersionList(QList rows, QObject* parent = nullptr) + : BaseVersionList(parent), m_rows(std::move(rows)) + { + } + + void setLoaded(bool loaded) + { + m_isLoaded = loaded; + } + void setLoadTask(Task::Ptr task) + { + m_loadTask = task; + } + + // BaseVersionList + Task::Ptr getLoadTask() override + { + return m_loadTask; + } + bool isLoaded() override + { + return m_isLoaded; + } + const BaseVersionPtr at(int) const override + { + return BaseVersionPtr(); + } + int count() const override + { + return m_rows.count(); + } + void sortVersions() override {} + + // QAbstractListModel + QVariant data(const QModelIndex& index, int role) const override + { + if (!index.isValid() || index.row() < 0 || + index.row() >= m_rows.count()) { + return QVariant(); + } + const Row& row = m_rows.at(index.row()); + switch (role) { + case VersionIdRole: + return row.versionId; + case TypeRole: + return row.type; + case ParentVersionRole: + return row.parentGameVersion; + case SortRole: + return row.sort; + default: + return QVariant(); + } + } + + QHash roleNames() const override + { + return { + { VersionIdRole, "versionId" }, + { TypeRole, "type" }, + { ParentVersionRole, "parentGameVersion" }, + { SortRole, "sort" }, + }; + } + + protected: + // Overrides a slot, but adds none of its own - no Q_OBJECT needed + // here, same as FakeInstanceModel in InstanceFilterModel_test.cpp. + void updateListData(QList) override {} + + private: + QList m_rows; + bool m_isLoaded = true; + Task::Ptr m_loadTask; + }; + + /* A Task that does nothing on its own - executeTask() is a no-op - so + * the test can drive success/failure by hand, same idiom as + * tasks/TaskWatcher_test.cpp's ScriptedTask. */ + class ScriptedTask : public Task + { + Q_OBJECT + public: + using Task::Task; + + void driveSuccess() + { + emitSucceeded(); + } + void driveFailure(const QString& reason) + { + emitFailed(reason); + } + + protected: + void executeTask() override {} + }; +} // namespace + +class NewInstanceControllerTest : public QObject +{ + Q_OBJECT + + private slots: + + /// Release-only, newest first, is the default - same starting point as + /// VanillaPage's checkboxes (ui/pages/modplatform/VanillaPage.cpp). + void test_minecraftVersions_defaultsToReleaseOnly_newestFirst() + { + FakeVersionList list({ + { "1.19", "release", "", 50 }, + { "1.20", "release", "", 100 }, + { "1.20.1-rc1", "snapshot", "", 150 }, + { "b1.7.3", "old_beta", "", 10 }, + }); + + MinecraftVersionListProxy proxy; + proxy.setSourceModel(&list); + + QCOMPARE(proxy.count(), 2); + QCOMPARE(proxy.index(0, 0).data(FakeVersionList::VersionIdRole).toString(), + QString("1.20")); + QCOMPARE(proxy.index(1, 0).data(FakeVersionList::VersionIdRole).toString(), + QString("1.19")); + QCOMPARE(proxy.firstVersionId(), QString("1.20")); + } + + /// showSnapshots/showOldVersions widen the filter without disturbing the + /// newest-first order. + void test_minecraftVersions_showSnapshotsAndShowOldVersions_widenFilter() + { + FakeVersionList list({ + { "1.19", "release", "", 50 }, + { "1.20", "release", "", 100 }, + { "1.20.1-rc1", "snapshot", "", 150 }, + { "b1.7.3", "old_beta", "", 10 }, + }); + + MinecraftVersionListProxy proxy; + proxy.setSourceModel(&list); + + QSignalSpy countSpy(&proxy, &VersionListLoadingProxy::countChanged); + proxy.setShowSnapshots(true); + QCOMPARE(proxy.count(), 3); + QCOMPARE(proxy.firstVersionId(), QString("1.20.1-rc1")); + QVERIFY(!countSpy.isEmpty()); + + proxy.setShowOldVersions(true); + QCOMPARE(proxy.count(), 4); + } + + /// A type neither toggle names (an "experiment" build, say) always stays + /// hidden - see the class comment in NewInstanceController.h. + void test_minecraftVersions_hidesUnrecognizedType() + { + FakeVersionList list({ + { "1.20", "release", "", 100 }, + { "20w14infinite", "experiment", "", 200 }, + }); + + MinecraftVersionListProxy proxy; + proxy.setSourceModel(&list); + proxy.setShowSnapshots(true); + proxy.setShowOldVersions(true); + + QCOMPARE(proxy.count(), 1); + QCOMPARE(proxy.firstVersionId(), QString("1.20")); + } + + /// loading() / error() mirror the load task exactly like TaskWatcher + /// mirrors the tasks it watches (tasks/TaskWatcher.h). + void test_minecraftVersions_loading_tracksLoadTaskLifecycle() + { + FakeVersionList list({}); + list.setLoaded(false); + auto* scripted = new ScriptedTask(); + list.setLoadTask(Task::Ptr(scripted)); + + MinecraftVersionListProxy proxy; + QSignalSpy loadingSpy(&proxy, &VersionListLoadingProxy::loadingChanged); + proxy.setSourceModel(&list); + + QVERIFY(proxy.loading()); + QVERIFY(scripted->isRunning()); + QCOMPARE(loadingSpy.count(), 1); + + scripted->driveFailure("could not reach the metadata server"); + QVERIFY(!proxy.loading()); + QCOMPARE(proxy.error(), + QString("could not reach the metadata server")); + } + + /// "Exact if present": a build naming no Minecraft version (Fabric, + /// Quilt) is always kept; one that does must match exactly - the same + /// rule LoaderVersionPage applies (ui/dialogs/InstallLoaderDialog.cpp). + void test_loaderVersions_exactIfPresentFilter() + { + FakeVersionList list({ + { "47.1.0", "release", "1.20.1", 90 }, + { "47.2.0", "release", "1.20.1", 100 }, + { "48.0.0", "release", "1.21", 110 }, + { "0.15.0", "release", "", 120 }, + }); + + LoaderVersionListProxy proxy; + proxy.setSourceModel(&list); + QCOMPARE(proxy.count(), 4); + + proxy.setMinecraftVersion("1.20.1"); + QCOMPARE(proxy.count(), 3); + // Newest first among what is left: the parent-less build (120) + // still outranks both matching Forge builds. + QCOMPARE(proxy.firstVersionId(), QString("0.15.0")); + + proxy.setMinecraftVersion("1.21"); + QCOMPARE(proxy.count(), 2); + QCOMPARE(proxy.firstVersionId(), QString("0.15.0")); + } + + void test_composeSuggestedInstanceName() + { + QCOMPARE(composeSuggestedInstanceName(QString(), QString()), QString()); + QCOMPARE(composeSuggestedInstanceName("1.21.1", QString()), + QString("1.21.1")); + QCOMPARE(composeSuggestedInstanceName("1.21.1", "fabric"), + QString("1.21.1 Fabric")); + QCOMPARE(composeSuggestedInstanceName("1.21.1", "quilt"), + QString("1.21.1 Quilt")); + QCOMPARE(composeSuggestedInstanceName("1.21.1", "forge"), + QString("1.21.1 Forge")); + QCOMPARE(composeSuggestedInstanceName("1.21.1", "neoforge"), + QString("1.21.1 NeoForge")); + // An unrecognised loader name is treated like "none" rather than + // crashing or guessing. + QCOMPARE(composeSuggestedInstanceName("1.21.1", "not-a-loader"), + QString("1.21.1")); + } + + void test_suggestedImportName() + { + QCOMPARE(suggestedImportName(""), QString()); + QCOMPARE(suggestedImportName(" "), QString()); + + // A bare local path - the common case for a FileDialog pick or a + // drag-drop. + QCOMPARE(suggestedImportName("/home/user/Downloads/Cool Modpack.mrpack"), + QString("Cool Modpack")); + // A "file://" URL, percent-encoded, is just as local. + QCOMPARE( + suggestedImportName("file:///home/user/Downloads/Cool%20Pack.zip"), + QString("Cool Pack")); + + // A pasted direct-download link. + QCOMPARE(suggestedImportName( + "https://cdn.modrinth.com/data/AAAA/versions/1.0/" + "My-Pack-1.0.mrpack"), + QString("My-Pack-1.0")); + + // CurseForge's own "download" button links end this way; the real + // file name sits behind the same rewrite ImportPage::updateState() + // applies before reading it - see suggestedImportName()'s comment. + QCOMPARE(suggestedImportName("https://www.curseforge.com/api/v1/mods/1/" + "files/2/download?client=y"), + QString("file")); + } + + /// The gate the QML Import button uses before it lets InstanceImportTask + /// even try - see importSourceLooksValid()'s comment. + void test_importSourceLooksValid() + { + QVERIFY(!importSourceLooksValid("")); + QVERIFY(!importSourceLooksValid(" ")); + + // A remote link can't be checked locally - only InstanceImportTask + // can tell whether it actually resolves to something importable. + QVERIFY(importSourceLooksValid( + "https://cdn.modrinth.com/data/AAAA/versions/1.0/" + "My-Pack-1.0.mrpack")); + + // A local path that does not exist is always rejected, archive + // extension or not. + QVERIFY(!importSourceLooksValid("/no/such/path/Cool Modpack.mrpack")); + + QTemporaryDir dir; + QVERIFY(dir.isValid()); + + // Exists, but not an archive extension. + const QString textPath = dir.filePath("notes.txt"); + QFile textFile(textPath); + QVERIFY(textFile.open(QIODevice::WriteOnly)); + textFile.close(); + QVERIFY(!importSourceLooksValid(textPath)); + + // Exists and looks like a modpack archive. + const QString packPath = dir.filePath("Cool Modpack.mrpack"); + QFile packFile(packPath); + QVERIFY(packFile.open(QIODevice::WriteOnly)); + packFile.close(); + QVERIFY(importSourceLooksValid(packPath)); + } +}; + +QTEST_GUILESS_MAIN(NewInstanceControllerTest) + +#include "NewInstanceController_test.moc" diff --git a/launcher/models/OtherLogsModel.cpp b/launcher/models/OtherLogsModel.cpp new file mode 100644 index 00000000..19379cf1 --- /dev/null +++ b/launcher/models/OtherLogsModel.cpp @@ -0,0 +1,182 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "OtherLogsModel.h" + +#include +#include + +#include "FileSystem.h" +#include "GZip.h" +#include "RecursiveFileSystemWatcher.h" + +namespace +{ + // Mirrors OtherLogsPage::on_btnReload_clicked()'s own ceilings. + constexpr qint64 kTooBigToOpen = 1024ll * 1024ll * 12ll; + constexpr qint64 kTooBigToShow = 50000000ll; +} // namespace + +OtherLogsModel::OtherLogsModel(const QString& path, IPathMatcher::Ptr fileFilter, + QObject* parent) + : QAbstractListModel(parent), m_path(path), + m_watcher(new RecursiveFileSystemWatcher(this)) +{ + m_watcher->setMatcher(std::move(fileFilter)); + m_watcher->setRootDir(QDir::current().absoluteFilePath(m_path)); + connect(m_watcher, &RecursiveFileSystemWatcher::filesChanged, this, + &OtherLogsModel::onFilesChanged); + m_watcher->enable(); +} + +OtherLogsModel::~OtherLogsModel() +{ + m_watcher->disable(); +} + +int OtherLogsModel::rowCount(const QModelIndex& parent) const +{ + if (parent.isValid()) { + return 0; + } + return m_watcher->files().size(); +} + +QVariant OtherLogsModel::data(const QModelIndex& index, int role) const +{ + if (!index.isValid() || index.row() < 0 || + index.row() >= m_watcher->files().size()) { + return QVariant(); + } + switch (role) { + case Qt::DisplayRole: + case NameRole: + return m_watcher->files().at(index.row()); + default: + return QVariant(); + } +} + +QHash OtherLogsModel::roleNames() const +{ + QHash roles = QAbstractListModel::roleNames(); + roles.insert(NameRole, "name"); + return roles; +} + +void OtherLogsModel::onFilesChanged() +{ + beginResetModel(); + endResetModel(); + + // The selected file may have just been deleted (or renamed) out from + // under us - same rule OtherLogsPage::populateSelectLogBox() applies. + if (!m_currentFile.isEmpty() && + !QFile::exists(FS::PathCombine(m_path, m_currentFile))) { + selectFile(QString()); + } +} + +void OtherLogsModel::selectFile(const QString& name) +{ + if (m_currentFile == name) { + return; + } + m_currentFile = name; + emit currentFileChanged(); + reload(); +} + +void OtherLogsModel::setContent(const QString& text) +{ + if (m_content == text) { + return; + } + m_content = text; + emit contentChanged(); +} + +void OtherLogsModel::reload() +{ + if (m_currentFile.isEmpty()) { + setContent(QString()); + return; + } + QFile file(FS::PathCombine(m_path, m_currentFile)); + if (!file.open(QFile::ReadOnly)) { + setContent(tr("Unable to open %1 for reading: %2") + .arg(m_currentFile, file.errorString())); + return; + } + if (file.size() > kTooBigToOpen) { + setContent(tr("The file (%1) is too big. You may want to open it in " + "a viewer optimized for large files.") + .arg(file.fileName())); + return; + } + QString content; + if (file.fileName().endsWith(QStringLiteral(".gz"))) { + QByteArray uncompressed; + if (!GZip::unzip(file.readAll(), uncompressed)) { + setContent(tr("The file (%1) is not readable.").arg(file.fileName())); + return; + } + content = QString::fromUtf8(uncompressed); + } else { + content = QString::fromUtf8(file.readAll()); + } + if (content.size() >= kTooBigToShow) { + setContent(tr("The file (%1) is too big. You may want to open it in " + "a viewer optimized for large files.") + .arg(file.fileName())); + return; + } + setContent(content); +} + +bool OtherLogsModel::deleteCurrent() +{ + if (m_currentFile.isEmpty()) { + return false; + } + QFile file(FS::PathCombine(m_path, m_currentFile)); + if (!file.remove()) { + return false; + } + selectFile(QString()); + return true; +} + +QStringList OtherLogsModel::deleteAll() +{ + QStringList failed; + for (const QString& name : m_watcher->files()) { + QFile file(FS::PathCombine(m_path, name)); + if (!file.remove()) { + failed.append(name); + } + } + // Same immediate check onFilesChanged() does once the filesystem + // watcher notices - not worth waiting for that here too. + if (!m_currentFile.isEmpty() && + !QFile::exists(FS::PathCombine(m_path, m_currentFile))) { + selectFile(QString()); + } + return failed; +} diff --git a/launcher/models/OtherLogsModel.h b/launcher/models/OtherLogsModel.h new file mode 100644 index 00000000..8c72deb8 --- /dev/null +++ b/launcher/models/OtherLogsModel.h @@ -0,0 +1,109 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include + +#include "pathmatcher/IPathMatcher.h" + +class RecursiveFileSystemWatcher; + +/* + * QML-facing bridge to an instance's "other" log files - logs/*.log* and + * crash-reports/*.txt under its game root - the widget-free replacement for + * OtherLogsPage. A plain list model of file names (role `name`); picking one + * loads its text into `content`, gzip-decompressed the same way + * OtherLogsPage::on_btnReload_clicked() does. + * + * Watches @p path for as long as this bridge lives (RecursiveFileSystemWatcher + * itself, not the open/close toggling OtherLogsPage does per its BasePage + * lifecycle) - InstanceDetails, which owns this, already only lives for as + * long as the instance page is open, so there is no separate "page is + * visible" state to toggle it against here. + */ +class OtherLogsModel : public QAbstractListModel +{ + Q_OBJECT + + Q_PROPERTY(QString path READ path CONSTANT) + /// The file selectFile() was last called with, or empty. + Q_PROPERTY(QString currentFile READ currentFile NOTIFY currentFileChanged) + /// `currentFile`'s text, or a placeholder for "too big to show" / "not + /// readable" - see reload(), which mirrors OtherLogsPage's own + /// handling of both. + Q_PROPERTY(QString content READ content NOTIFY contentChanged) + + public: + enum Roles { NameRole = Qt::UserRole }; + + /// @p path / @p fileFilter: same as BaseInstance::getLogFileRoot() / + /// getLogFileMatcher(). + explicit OtherLogsModel(const QString& path, IPathMatcher::Ptr fileFilter, + QObject* parent = nullptr); + ~OtherLogsModel() override; + + int rowCount(const QModelIndex& parent = QModelIndex()) const override; + QVariant data(const QModelIndex& index, + int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + + QString path() const + { + return m_path; + } + QString currentFile() const + { + return m_currentFile; + } + QString content() const + { + return m_content; + } + + /// Selects @p name (as listed by this model) and loads its content; + /// clears both if @p name is empty or no longer exists. + Q_INVOKABLE void selectFile(const QString& name); + /// Re-reads `currentFile` from disk - for a "Reload" action, or after + /// deleteCurrent() elsewhere changes what is on disk. + Q_INVOKABLE void reload(); + /// Deletes `currentFile`. Returns false (and leaves it selected) if + /// the delete failed; clears the selection on success. + Q_INVOKABLE bool deleteCurrent(); + /// Deletes every file this model lists. Returns the names that could + /// not be removed (empty means every file was deleted). + Q_INVOKABLE QStringList deleteAll(); + + signals: + void currentFileChanged(); + void contentChanged(); + + private slots: + void onFilesChanged(); + + private: + void setContent(const QString& text); + + QString m_path; + QString m_currentFile; + QString m_content; + RecursiveFileSystemWatcher* m_watcher; +}; diff --git a/launcher/models/OtherLogsModel_test.cpp b/launcher/models/OtherLogsModel_test.cpp new file mode 100644 index 00000000..14c7ca90 --- /dev/null +++ b/launcher/models/OtherLogsModel_test.cpp @@ -0,0 +1,173 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include +#include +#include +#include + +#include "GZip.h" +#include "models/OtherLogsModel.h" +#include "pathmatcher/RegexpMatcher.h" + +namespace +{ +bool writeFile(const QString& path, const QByteArray& contents) +{ + QFile file(path); + if (!file.open(QIODevice::WriteOnly)) { + return false; + } + return file.write(contents) == contents.size(); +} + +/* Same filter shape as MinecraftInstance::getLogFileMatcher(): *.log + * (optionally .gz) and crash-*.txt, matched against paths relative to the + * watched root - see RecursiveFileSystemWatcher::scanRecursive(). */ +IPathMatcher::Ptr logFilter() +{ + return std::make_shared( + QStringLiteral(R"(.*\.log(\.gz)?$|crash-.*\.txt$)")); +} +} // namespace + +class OtherLogsModelTest : public QObject +{ + Q_OBJECT + private slots: + + void listsMatchingFilesOnlyAndExposesTheNameRole() + { + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + const QString dir = tempDir.path(); + QVERIFY(writeFile(QDir(dir).filePath("latest.log"), "hello")); + QVERIFY(QDir(dir).mkpath("crash-reports")); + QVERIFY(writeFile( + QDir(dir).filePath("crash-reports/crash-2026-01-01.txt"), "oops")); + // Not matched by the filter: must not show up at all. + QVERIFY(writeFile(QDir(dir).filePath("options.txt"), "ignored")); + + OtherLogsModel model(dir, logFilter()); + QCOMPARE(model.rowCount(), 2); + + QStringList names; + for (int i = 0; i < model.rowCount(); ++i) { + names.append( + model.data(model.index(i), OtherLogsModel::NameRole).toString()); + } + QVERIFY(names.contains(QStringLiteral("latest.log"))); + QVERIFY(names.contains( + QStringLiteral("crash-reports/crash-2026-01-01.txt"))); + } + + void selectFileLoadsPlainTextContent() + { + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + const QString dir = tempDir.path(); + QVERIFY(writeFile(QDir(dir).filePath("latest.log"), "line one\nline two")); + + OtherLogsModel model(dir, logFilter()); + model.selectFile(QStringLiteral("latest.log")); + + QCOMPARE(model.currentFile(), QStringLiteral("latest.log")); + QCOMPARE(model.content(), QStringLiteral("line one\nline two")); + } + + void selectFileDecompressesGzip() + { + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + const QString dir = tempDir.path(); + + const QByteArray original = "a rotated, compressed log line"; + QByteArray compressed; + QVERIFY(GZip::zip(original, compressed)); + QVERIFY(writeFile(QDir(dir).filePath("old.log.gz"), compressed)); + + OtherLogsModel model(dir, logFilter()); + model.selectFile(QStringLiteral("old.log.gz")); + + QCOMPARE(model.content(), QString::fromUtf8(original)); + } + + void selectingEmptyNameClearsContent() + { + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + const QString dir = tempDir.path(); + QVERIFY(writeFile(QDir(dir).filePath("latest.log"), "hi")); + + OtherLogsModel model(dir, logFilter()); + model.selectFile(QStringLiteral("latest.log")); + QCOMPARE(model.content(), QStringLiteral("hi")); + + model.selectFile(QString()); + QCOMPARE(model.currentFile(), QString()); + QCOMPARE(model.content(), QString()); + } + + void deleteCurrentRemovesFileAndClearsSelection() + { + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + const QString dir = tempDir.path(); + const QString path = QDir(dir).filePath("latest.log"); + QVERIFY(writeFile(path, "gone soon")); + + OtherLogsModel model(dir, logFilter()); + model.selectFile(QStringLiteral("latest.log")); + + QVERIFY(model.deleteCurrent()); + QVERIFY(!QFileInfo::exists(path)); + QCOMPARE(model.currentFile(), QString()); + QCOMPARE(model.content(), QString()); + } + + void deleteCurrentWithNoSelectionFails() + { + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + OtherLogsModel model(tempDir.path(), logFilter()); + QVERIFY(!model.deleteCurrent()); + } + + void deleteAllRemovesEveryListedFile() + { + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + const QString dir = tempDir.path(); + QVERIFY(writeFile(QDir(dir).filePath("a.log"), "1")); + QVERIFY(writeFile(QDir(dir).filePath("b.log"), "2")); + + OtherLogsModel model(dir, logFilter()); + QCOMPARE(model.rowCount(), 2); + + const QStringList failed = model.deleteAll(); + QVERIFY(failed.isEmpty()); + QVERIFY(!QFileInfo::exists(QDir(dir).filePath("a.log"))); + QVERIFY(!QFileInfo::exists(QDir(dir).filePath("b.log"))); + } +}; + +QTEST_GUILESS_MAIN(OtherLogsModelTest) + +#include "OtherLogsModel_test.moc" diff --git a/launcher/models/RecentWorldsModel.cpp b/launcher/models/RecentWorldsModel.cpp new file mode 100644 index 00000000..522aadb8 --- /dev/null +++ b/launcher/models/RecentWorldsModel.cpp @@ -0,0 +1,244 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "models/RecentWorldsModel.h" + +#include +#include +#include +#include +#include +#include + +#include "BaseInstance.h" +#include "InstanceList.h" +#include "minecraft/MinecraftInstance.h" +#include "minecraft/World.h" + +RecentWorldsModel::RecentWorldsModel(InstanceList* instances, QObject* parent) + : QAbstractListModel(parent), m_instances(instances) +{ + m_rescanTimer.setSingleShot(true); + connect(&m_rescanTimer, &QTimer::timeout, this, + &RecentWorldsModel::startScan); + connect(&m_watcher, &QFutureWatcher>::finished, this, + &RecentWorldsModel::onScanFinished); + + if (m_instances) { + connect(m_instances, &QAbstractItemModel::rowsInserted, this, + &RecentWorldsModel::scheduleRescan); + connect(m_instances, &QAbstractItemModel::rowsRemoved, this, + &RecentWorldsModel::scheduleRescan); + connect( + m_instances, &QAbstractItemModel::dataChanged, this, + [this](const QModelIndex& topLeft, const QModelIndex& bottomRight, + const QList& roles) { + // InstanceList::emitIsRunningChanged() is the only place + // that emits IsRunningRole, and it always names it + // explicitly (never with an empty role list) for a + // genuine running-state transition. Every other edit - + // rename, icon change, setLastLaunch, a managed-pack + // update, the hasCrashed flag, ... - goes through + // propertiesChanged() instead, which emits dataChanged + // with an empty role list; that is not a stop, so it must + // not trigger a rescan. + if (!roles.contains(InstanceList::IsRunningRole)) { + return; + } + for (int row = topLeft.row(); row <= bottomRight.row(); + ++row) { + const bool running = + m_instances + ->data(m_instances->index(row), + InstanceList::IsRunningRole) + .toBool(); + // Only a stop is interesting here: that is when a play + // session could have left a world behind, and rescanning + // on every start as well would just double the work for + // no gain. + if (!running) { + scheduleRescan(); + return; + } + } + }); + } + + scheduleRescan(); +} + +RecentWorldsModel::~RecentWorldsModel() = default; + +int RecentWorldsModel::rowCount(const QModelIndex& parent) const +{ + if (parent.isValid()) { + return 0; + } + return m_entries.size(); +} + +QVariant RecentWorldsModel::data(const QModelIndex& index, int role) const +{ + if (!index.isValid() || index.row() < 0 || + index.row() >= m_entries.size()) { + return QVariant(); + } + const Entry& entry = m_entries.at(index.row()); + switch (role) { + case WorldNameRole: + return entry.worldName; + case FolderNameRole: + return entry.folderName; + case IconUrlRole: + return entry.iconUrl; + case LastPlayedRole: + return entry.lastPlayed; + case InstanceIdRole: + return entry.instanceId; + case InstanceNameRole: + return entry.instanceName; + case InstanceIconKeyRole: + return entry.instanceIconKey; + default: + return QVariant(); + } +} + +QHash RecentWorldsModel::roleNames() const +{ + return { + {WorldNameRole, "worldName"}, + {FolderNameRole, "folderName"}, + {IconUrlRole, "iconUrl"}, + {LastPlayedRole, "lastPlayed"}, + {InstanceIdRole, "instanceId"}, + {InstanceNameRole, "instanceName"}, + {InstanceIconKeyRole, "instanceIconKey"}, + }; +} + +QList +RecentWorldsModel::scan(const QList& sources, + int maxCount) +{ + QList all; + for (const InstanceWorldSource& source : sources) { + QDir dir(source.worldsDir); + if (source.worldsDir.isEmpty() || !dir.exists()) { + continue; + } + const QFileInfoList entries = + dir.entryInfoList(QDir::Dirs | QDir::NoDotAndDotDot); + for (const QFileInfo& entry : entries) { + // Cheap rejection before the (relatively expensive) NBT parse + // World's constructor would otherwise do for every candidate on + // every rescan of every instance. + if (!QFileInfo(entry.absoluteFilePath() + "/level.dat") + .exists()) { + continue; + } + World world(entry); + if (!world.isValid()) { + continue; + } + Entry row; + row.worldName = world.name(); + row.folderName = world.folderName(); + row.iconUrl = world.iconFile().isEmpty() + ? QString() + : QUrl::fromLocalFile(world.iconFile()) + .toString(); + row.lastPlayed = world.lastPlayed().isValid() + ? world.lastPlayed().toMSecsSinceEpoch() + : 0; + row.instanceId = source.instanceId; + row.instanceName = source.instanceName; + row.instanceIconKey = source.instanceIconKey; + all.append(row); + } + } + + // Stable: several worlds tied on lastPlayed (most commonly several all + // at 0, i.e. no LastPlayed tag) would otherwise reorder nondeterminis- + // tically between rescans and flicker in the UI. + std::stable_sort(all.begin(), all.end(), [](const Entry& a, const Entry& b) { + return a.lastPlayed > b.lastPlayed; + }); + if (all.size() > maxCount) { + all.resize(maxCount); + } + return all; +} + +void RecentWorldsModel::refresh() +{ + m_rescanTimer.stop(); + startScan(); +} + +void RecentWorldsModel::scheduleRescan() +{ + m_rescanTimer.start(kRescanDebounceMs); +} + +void RecentWorldsModel::startScan() +{ + if (m_watcher.isRunning()) { + // Coalesced the same way the debounce timer coalesces a burst of + // triggers: one more scan once the current one is done, not a + // second one queued alongside it. + m_scanPending = true; + return; + } + m_scanPending = false; + + QList sources; + if (m_instances) { + const int count = m_instances->count(); + sources.reserve(count); + for (int i = 0; i < count; ++i) { + InstancePtr inst = m_instances->at(i); + auto* mcInst = dynamic_cast(inst.get()); + if (!mcInst) { + continue; + } + InstanceWorldSource source; + source.instanceId = mcInst->id(); + source.instanceName = mcInst->name(); + source.instanceIconKey = mcInst->iconKey(); + source.worldsDir = mcInst->worldDir(); + sources.append(source); + } + } + + m_watcher.setFuture(QtConcurrent::run(QThreadPool::globalInstance(), + &RecentWorldsModel::scan, sources, + kMaxWorlds)); +} + +void RecentWorldsModel::onScanFinished() +{ + beginResetModel(); + m_entries = m_watcher.result(); + endResetModel(); + + if (m_scanPending) { + startScan(); + } +} diff --git a/launcher/models/RecentWorldsModel.h b/launcher/models/RecentWorldsModel.h new file mode 100644 index 00000000..1d2f1019 --- /dev/null +++ b/launcher/models/RecentWorldsModel.h @@ -0,0 +1,143 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include +#include + +class InstanceList; + +/* + * The Home page's "Recent worlds" row: the most recently played worlds + * across every Minecraft instance, newest first (see the approved Home page + * spec - "Jump back in" shows recent instances, this shows recent worlds + * underneath). Roles: worldName, folderName, iconUrl (file:// URL of the + * world's icon.png, or empty), lastPlayed (ms since epoch, 0 if unknown), + * instanceId, instanceName, instanceIconKey. + * + * SCANNING. Reading every instance's saves folder - a directory listing + * plus a level.dat parse per world - is not free, and the spec requires + * zero impact on startup. scan() below does that work; it is a static, pure + * function (only plain value types in and out, no QObject, no `this`) so it + * can run on a QThreadPool worker thread via QtConcurrent::run() without + * touching anything the GUI thread might be using concurrently, and so it + * can be unit-tested directly against a temporary directory without a real + * InstanceList or MinecraftInstance. startScan() gathers the per-instance + * inputs (id/name/iconKey/worldsDir - all plain QStrings, copied by value) + * on the GUI thread, which is cheap, then hands them to the worker; the + * result comes back through a QFutureWatcher and is applied with a model + * reset on the GUI thread. Destroying the model while a scan is in flight + * is safe without any extra guard: m_watcher is a member, so it is + * destroyed synchronously (on the GUI thread) before the model itself is + * gone, and once it is destroyed its finished() signal can no longer fire - + * the worker keeps running to completion (it holds no reference back to + * this object) but its result is simply never delivered, the same + * destruction pattern InstanceCopyTask/ExtractZipTask already rely on for + * their own QFutureWatcher members. + * + * RESCAN TRIGGERS. Construction, an instance being added or removed + * (InstanceList's rowsInserted/rowsRemoved - loadList() emits genuine Qt + * model signals for these, unlike instancesChanged() which fires before the + * reload happens), and an instance's IsRunningRole flipping to false (it + * may have played a world during that session). All three go through + * scheduleRescan(), which (re)starts a short single-shot timer, so a burst + * of them - e.g. several instances loading at once - collapses into one + * scan, the same coalescing ScreenshotListModel's m_refreshTimer does for + * filesystem-watcher events. + */ +class RecentWorldsModel : public QAbstractListModel +{ + Q_OBJECT + + public: + enum Roles { + WorldNameRole = Qt::UserRole + 1, + FolderNameRole, + IconUrlRole, + LastPlayedRole, + InstanceIdRole, + InstanceNameRole, + InstanceIconKeyRole, + }; + + /* One instance's worlds folder plus the bit of instance metadata a + * "Recent worlds" row needs about where it came from. Plain QStrings + * only - see the class comment's SCANNING section for why. */ + struct InstanceWorldSource { + QString instanceId; + QString instanceName; + QString instanceIconKey; + QString worldsDir; + }; + + /// One row of the model - see the class comment for what each field is. + struct Entry { + QString worldName; + QString folderName; + QString iconUrl; + qint64 lastPlayed = 0; + QString instanceId; + QString instanceName; + QString instanceIconKey; + }; + + /* @p instances may be null (an empty model, never rescanned) for a + * caller with nothing to show yet; production code passes + * LAUNCHER->instances().get(), the way QmlShell's other models are + * built against it. Not owned - InstanceList outlives this the same + * way it outlives InstanceFilterModel. */ + explicit RecentWorldsModel(InstanceList* instances, + QObject* parent = nullptr); + ~RecentWorldsModel() override; + + int rowCount(const QModelIndex& parent = QModelIndex()) const override; + QVariant data(const QModelIndex& index, int role) const override; + QHash roleNames() const override; + + /* The @p maxCount most recently played worlds across every source, + * newest first. Pure and static - see the class comment's SCANNING + * section. Skips anything that is not a directory or has no level.dat, + * the same shortcut World::isValid() gives WorldList::update(). */ + static QList scan(const QList& sources, + int maxCount); + + /// Re-scans right away, bypassing the debounce timer - for a caller + /// that already knows this is a good moment to look (a page opening). + Q_INVOKABLE void refresh(); + + private: + void scheduleRescan(); + void startScan(); + void onScanFinished(); + + InstanceList* m_instances; + QList m_entries; + QTimer m_rescanTimer; + QFutureWatcher> m_watcher; + /// A rescan was asked for while one was already running; picked up by + /// onScanFinished() instead of being dropped on the floor. + bool m_scanPending = false; + + static constexpr int kMaxWorlds = 8; + static constexpr int kRescanDebounceMs = 250; +}; diff --git a/launcher/models/RecentWorldsModel_test.cpp b/launcher/models/RecentWorldsModel_test.cpp new file mode 100644 index 00000000..2bfde995 --- /dev/null +++ b/launcher/models/RecentWorldsModel_test.cpp @@ -0,0 +1,501 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include +#include +#include +#include +#include + +#include "models/RecentWorldsModel.h" + +#include "GZip.h" +#include "InstanceList.h" +#include "NullInstance.h" +#include "settings/INISettingsObject.h" + +/* + * Exercises RecentWorldsModel::scan() directly against a temporary + * directory - the pure, static part of the model that does the actual + * filesystem/level.dat work (see the class comment's SCANNING section) and + * is what a rescan actually gets right or wrong. Constructing a real + * InstanceList of real MinecraftInstance objects, just to drive scan() + * itself through the model's InstanceList* constructor, would need a full + * instance setup (PackProfile, a version, ...) far beyond what scanning + * worlds needs to be tested - the same tradeoff models/ContentBrowser_test.cpp + * and models/InstanceDetails_test.cpp make. + * + * The async orchestration around scan() - scheduleRescan()'s rescan + * triggers and debounce/coalescing - is a different concern and does not + * need a real MinecraftInstance to exercise: the tests near the bottom of + * this file build a real InstanceList with the same NullInstance fixture + * InstanceList_test.cpp uses for HasCrashedRole, which is enough to drive + * InstanceList's rowsInserted/rowsRemoved/dataChanged signals. + */ +namespace +{ + using Entry = RecentWorldsModel::Entry; + using Source = RecentWorldsModel::InstanceWorldSource; + + /* + * Minimal big-endian NBT writer - just enough level.dat to make + * World::isValid() true and give it a LastPlayed value, the only two + * things scan() reads through World. Reduced copy of the writer + * minecraft/World_test.cpp keeps for its own (much larger) set of + * level.dat shapes; not shared with it because both are private to + * their own translation unit. + */ + const quint8 TAG_END = 0; + const quint8 TAG_LONG = 4; + const quint8 TAG_COMPOUND = 10; + + void putU8(QByteArray& out, quint8 value) + { + out.append(static_cast(value)); + } + + void putU16(QByteArray& out, quint16 value) + { + out.append(static_cast((value >> 8) & 0xFF)); + out.append(static_cast(value & 0xFF)); + } + + void putI64(QByteArray& out, qint64 value) + { + for (int shift = 56; shift >= 0; shift -= 8) { + out.append(static_cast((value >> shift) & 0xFF)); + } + } + + void putString(QByteArray& out, const QByteArray& value) + { + putU16(out, static_cast(value.size())); + out.append(value); + } + + void putTagHeader(QByteArray& out, quint8 type, const QByteArray& name) + { + putU8(out, type); + putString(out, name); + } + + void putLongTag(QByteArray& out, const QByteArray& name, qint64 value) + { + putTagHeader(out, TAG_LONG, name); + putI64(out, value); + } + + QByteArray makeLevelDat(qint64 lastPlayedMs) + { + QByteArray dataPayload; + putLongTag(dataPayload, "LastPlayed", lastPlayedMs); + putU8(dataPayload, TAG_END); + + QByteArray root; + putTagHeader(root, TAG_COMPOUND, ""); // unnamed root compound + putTagHeader(root, TAG_COMPOUND, "Data"); + root.append(dataPayload); + putU8(root, TAG_END); + return root; + } + + /// Writes a valid, minimal world folder at @p worldPath. + bool writeWorld(const QString& worldPath, qint64 lastPlayedMs) + { + if (!QDir().mkpath(worldPath)) { + return false; + } + QByteArray compressed; + if (!GZip::zip(makeLevelDat(lastPlayedMs), compressed)) { + return false; + } + QFile file(QDir(worldPath).filePath("level.dat")); + if (!file.open(QIODevice::WriteOnly)) { + return false; + } + return file.write(compressed) == compressed.size(); + } + + Source makeSource(const QString& worldsDir, + const QString& instanceId = "inst") + { + Source source; + source.instanceId = instanceId; + source.instanceName = instanceId; + source.instanceIconKey = "default"; + source.worldsDir = worldsDir; + return source; + } + + /* Same set of global settings InstanceList_test.cpp's + * makeGlobalSettings() registers - BaseInstance's constructor overrides + * or passes through exactly these ids, and a globalSettings without one + * of them makes registration hand back a null Setting. Not shared with + * that file for the same reason its own helpers are not shared here. */ + SettingsObjectPtr makeGlobalSettings(QTemporaryDir& dir) + { + auto settings = + std::make_shared(dir.filePath("global.ini")); + settings->registerSetting("PreLaunchCommand", ""); + settings->registerSetting("WrapperCommand", ""); + settings->registerSetting("PostExitCommand", ""); + settings->registerSetting("ShowConsole", true); + settings->registerSetting("AutoCloseConsole", false); + settings->registerSetting("ShowConsoleOnError", true); + settings->registerSetting("LogPrePostOutput", true); + settings->registerSetting("ConsoleMaxLines", 100000); + settings->registerSetting("ConsoleOverflowStop", true); + return settings; + } + + /// Writes a minimal instance.cfg with an InstanceType loadInstance() + /// does not recognize, so it falls back to NullInstance - the same + /// fixture shape InstanceList_test.cpp uses for HasCrashedRole. A real + /// MinecraftInstance is not needed here: these tests drive the async + /// scan orchestration (rescan triggers/coalescing), not scan() itself, + /// and a NullInstance is enough to exercise InstanceList's rowsInserted/ + /// rowsRemoved/dataChanged signals RecentWorldsModel listens to. + bool writeNullInstanceCfg(const QString& instRoot) + { + if (!QDir().mkpath(instRoot)) { + return false; + } + QFile file(QDir(instRoot).filePath("instance.cfg")); + if (!file.open(QIODevice::WriteOnly)) { + return false; + } + return file.write("InstanceType=NullTest\n") > 0; + } +} // namespace + +class RecentWorldsModelTest : public QObject +{ + Q_OBJECT + private slots: + + void ordersByLastPlayedNewestFirst() + { + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + const QString savesDir = QDir(tempDir.path()).filePath("saves"); + + QVERIFY(writeWorld(QDir(savesDir).filePath("old"), 1000)); + QVERIFY(writeWorld(QDir(savesDir).filePath("newest"), 3000)); + QVERIFY(writeWorld(QDir(savesDir).filePath("middle"), 2000)); + + const QList result = + RecentWorldsModel::scan({makeSource(savesDir)}, 8); + + QCOMPARE(result.size(), 3); + QCOMPARE(result.at(0).folderName, QString("newest")); + QCOMPARE(result.at(0).lastPlayed, Q_INT64_C(3000)); + QCOMPARE(result.at(1).folderName, QString("middle")); + QCOMPARE(result.at(2).folderName, QString("old")); + } + + void ordersAcrossMultipleInstances() + { + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + const QString savesA = QDir(tempDir.path()).filePath("a/saves"); + const QString savesB = QDir(tempDir.path()).filePath("b/saves"); + + QVERIFY(writeWorld(QDir(savesA).filePath("world"), 5000)); + QVERIFY(writeWorld(QDir(savesB).filePath("world"), 9000)); + + const QList result = RecentWorldsModel::scan( + {makeSource(savesA, "instA"), makeSource(savesB, "instB")}, 8); + + QCOMPARE(result.size(), 2); + QCOMPARE(result.at(0).instanceId, QString("instB")); + QCOMPARE(result.at(1).instanceId, QString("instA")); + } + + void capsAtMaxCount() + { + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + const QString savesDir = QDir(tempDir.path()).filePath("saves"); + + for (int i = 0; i < 5; ++i) { + QVERIFY(writeWorld( + QDir(savesDir).filePath(QString("world%1").arg(i)), + 1000 * (i + 1))); + } + + const QList result = + RecentWorldsModel::scan({makeSource(savesDir)}, 3); + + QCOMPARE(result.size(), 3); + // Still newest-first after the cap. + QCOMPARE(result.at(0).lastPlayed, Q_INT64_C(5000)); + QCOMPARE(result.at(1).lastPlayed, Q_INT64_C(4000)); + QCOMPARE(result.at(2).lastPlayed, Q_INT64_C(3000)); + } + + void skipsFoldersWithoutLevelDat() + { + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + const QString savesDir = QDir(tempDir.path()).filePath("saves"); + + QVERIFY(writeWorld(QDir(savesDir).filePath("real"), 1000)); + QVERIFY(QDir().mkpath(QDir(savesDir).filePath("empty-folder"))); + + const QList result = + RecentWorldsModel::scan({makeSource(savesDir)}, 8); + + QCOMPARE(result.size(), 1); + QCOMPARE(result.at(0).folderName, QString("real")); + } + + void ignoresFilesNextToWorldFolders() + { + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + const QString savesDir = QDir(tempDir.path()).filePath("saves"); + + QVERIFY(writeWorld(QDir(savesDir).filePath("real"), 1000)); + QFile stray(QDir(savesDir).filePath("session.lock")); + QVERIFY(stray.open(QIODevice::WriteOnly)); + stray.write("x"); + stray.close(); + + const QList result = + RecentWorldsModel::scan({makeSource(savesDir)}, 8); + + QCOMPARE(result.size(), 1); + } + + void reportsWorldIconUrlWhenPresent() + { + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + const QString savesDir = QDir(tempDir.path()).filePath("saves"); + const QString worldPath = QDir(savesDir).filePath("iconworld"); + + QVERIFY(writeWorld(worldPath, 1000)); + QFile icon(QDir(worldPath).filePath("icon.png")); + QVERIFY(icon.open(QIODevice::WriteOnly)); + icon.write("not a real png, world only reads the path"); + icon.close(); + + const QList result = + RecentWorldsModel::scan({makeSource(savesDir)}, 8); + + QCOMPARE(result.size(), 1); + QCOMPARE(result.at(0).iconUrl, + QUrl::fromLocalFile(QDir(worldPath).filePath("icon.png")) + .toString()); + } + + void emptyIconUrlWithoutIcon() + { + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + const QString savesDir = QDir(tempDir.path()).filePath("saves"); + + QVERIFY(writeWorld(QDir(savesDir).filePath("noicon"), 1000)); + + const QList result = + RecentWorldsModel::scan({makeSource(savesDir)}, 8); + + QCOMPARE(result.size(), 1); + QCOMPARE(result.at(0).iconUrl, QString()); + } + + void missingOrEmptyWorldsDirIsNotAnError() + { + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + + const QList missing = RecentWorldsModel::scan( + {makeSource(QDir(tempDir.path()).filePath("does-not-exist"))}, 8); + QVERIFY(missing.isEmpty()); + + const QList empty = + RecentWorldsModel::scan({makeSource(QString())}, 8); + QVERIFY(empty.isEmpty()); + + QCOMPARE(RecentWorldsModel::scan({}, 8).size(), 0); + } + + void carriesInstanceMetadataThrough() + { + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + const QString savesDir = QDir(tempDir.path()).filePath("saves"); + QVERIFY(writeWorld(QDir(savesDir).filePath("world"), 1000)); + + Source source = makeSource(savesDir, "the-instance"); + source.instanceName = "The Instance"; + source.instanceIconKey = "grass"; + + const QList result = RecentWorldsModel::scan({source}, 8); + + QCOMPARE(result.size(), 1); + QCOMPARE(result.at(0).instanceId, QString("the-instance")); + QCOMPARE(result.at(0).instanceName, QString("The Instance")); + QCOMPARE(result.at(0).instanceIconKey, QString("grass")); + } + + /// A null InstanceList (nothing to show yet) is a valid, empty model + /// rather than a crash - see the constructor's doc comment. + void nullInstanceListIsAnEmptyModel() + { + RecentWorldsModel model(nullptr); + QCOMPARE(model.rowCount(), 0); + + QSignalSpy resetSpy(&model, &QAbstractItemModel::modelReset); + model.refresh(); + QVERIFY(resetSpy.wait()); + QCOMPARE(model.rowCount(), 0); + } + + void roleNamesMatchExpectedRoles() + { + RecentWorldsModel model(nullptr); + const QHash roles = model.roleNames(); + QCOMPARE(roles.value(RecentWorldsModel::WorldNameRole), + QByteArray("worldName")); + QCOMPARE(roles.value(RecentWorldsModel::FolderNameRole), + QByteArray("folderName")); + QCOMPARE(roles.value(RecentWorldsModel::IconUrlRole), + QByteArray("iconUrl")); + QCOMPARE(roles.value(RecentWorldsModel::LastPlayedRole), + QByteArray("lastPlayed")); + QCOMPARE(roles.value(RecentWorldsModel::InstanceIdRole), + QByteArray("instanceId")); + QCOMPARE(roles.value(RecentWorldsModel::InstanceNameRole), + QByteArray("instanceName")); + QCOMPARE(roles.value(RecentWorldsModel::InstanceIconKeyRole), + QByteArray("instanceIconKey")); + } + + /* + * The tests below drive a real InstanceList (NullInstance fixture, as + * InstanceList_test.cpp uses for HasCrashedRole) instead of scan(): they + * cover scheduleRescan()'s rescan triggers and debounce/coalescing, not + * the filesystem scan itself. A NullInstance is not a MinecraftInstance, + * so startScan() always hands scan() an empty source list here - the + * model reset each rescan produces is still observable and is all these + * tests need. + */ + + void rowsInsertedTriggersRescan() + { + QTemporaryDir globalDir; + QVERIFY(globalDir.isValid()); + QTemporaryDir instsDir; + QVERIFY(instsDir.isValid()); + SettingsObjectPtr globalSettings = makeGlobalSettings(globalDir); + + InstanceList list(globalSettings, {instsDir.path()}); + QCOMPARE(list.loadList(), InstanceList::NoError); + + RecentWorldsModel model(&list); + QSignalSpy resetSpy(&model, &QAbstractItemModel::modelReset); + // Construction schedules its own initial scan. + QVERIFY(resetSpy.wait()); + resetSpy.clear(); + + // InstanceList::add() (via loadList() discovering a new instance) + // emits a genuine rowsInserted - see the class comment's RESCAN + // TRIGGERS section for why that, not instancesChanged(), is what + // scheduleRescan() is wired to. + const QString instRoot = QDir(instsDir.path()).filePath("newinst"); + QVERIFY(writeNullInstanceCfg(instRoot)); + QCOMPARE(list.loadList(), InstanceList::NoError); + + QVERIFY(resetSpy.wait()); + } + + void stoppingARunningInstanceTriggersRescan() + { + QTemporaryDir globalDir; + QVERIFY(globalDir.isValid()); + QTemporaryDir instsDir; + QVERIFY(instsDir.isValid()); + SettingsObjectPtr globalSettings = makeGlobalSettings(globalDir); + + const QString instRoot = QDir(instsDir.path()).filePath("testinst"); + QVERIFY(writeNullInstanceCfg(instRoot)); + + InstanceList list(globalSettings, {instsDir.path()}); + QCOMPARE(list.loadList(), InstanceList::NoError); + InstancePtr inst = list.getInstanceById("testinst"); + QVERIFY(inst); + + RecentWorldsModel model(&list); + QSignalSpy resetSpy(&model, &QAbstractItemModel::modelReset); + QVERIFY(resetSpy.wait()); + resetSpy.clear(); + + // Starting a run alone must not schedule a rescan - only a stop is + // interesting (see the class comment). + inst->setRunning(true); + QVERIFY(!resetSpy.wait(500)); + + inst->setRunning(false); + QVERIFY(resetSpy.wait()); + } + + void unrelatedPropertyChangeDoesNotTriggerRescan() + { + QTemporaryDir globalDir; + QVERIFY(globalDir.isValid()); + QTemporaryDir instsDir; + QVERIFY(instsDir.isValid()); + SettingsObjectPtr globalSettings = makeGlobalSettings(globalDir); + + const QString instRoot = QDir(instsDir.path()).filePath("testinst"); + QVERIFY(writeNullInstanceCfg(instRoot)); + + InstanceList list(globalSettings, {instsDir.path()}); + QCOMPARE(list.loadList(), InstanceList::NoError); + InstancePtr inst = list.getInstanceById("testinst"); + QVERIFY(inst); + + RecentWorldsModel model(&list); + QSignalSpy resetSpy(&model, &QAbstractItemModel::modelReset); + QVERIFY(resetSpy.wait()); + resetSpy.clear(); + + // A rename, an icon change and the hasCrashed flag all go through + // InstanceList::propertiesChanged(), which emits dataChanged with + // an empty role list - not IsRunningRole, so none of these must + // schedule a rescan. + inst->setName("Renamed"); + inst->setIconKey("grass"); + inst->setCrashed(true); + QVERIFY(!resetSpy.wait(500)); + + // Sanity check that the spy above would have caught a real + // rescan: a genuine running -> stopped transition still schedules + // one. + inst->setRunning(true); + inst->setRunning(false); + QVERIFY(resetSpy.wait()); + } +}; + +QTEST_GUILESS_MAIN(RecentWorldsModelTest) + +#include "RecentWorldsModel_test.moc" diff --git a/launcher/models/ServersListModel.cpp b/launcher/models/ServersListModel.cpp new file mode 100644 index 00000000..13fae629 --- /dev/null +++ b/launcher/models/ServersListModel.cpp @@ -0,0 +1,371 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "ServersListModel.h" + +#include +#include +#include +#include + +#include "FileSystem.h" +#include +#include +#include +#include +#include +#include + +namespace +{ +/* Same shape as ServersPage.cpp's file-local parseServersDat()/ + * serializeServerDat(): duplicated rather than shared, because the widget + * version is a static function private to that .cpp, and the whole point + * of this class is that nothing here links against ui/. */ +std::unique_ptr parseServersDat(const QString& filename) +{ + // A fresh instance has no servers.dat yet: that is the normal empty + // state, not an error worth a critical log line from FS::read(). + if (!QFileInfo::exists(filename)) { + return nullptr; + } + try { + QByteArray input = FS::read(filename); + std::istringstream stream(std::string(input.constData(), input.size())); + auto pair = nbt::io::read_compound(stream); + if (pair.first != "" || pair.second == nullptr) { + return nullptr; + } + return std::move(pair.second); + } catch (...) { + return nullptr; + } +} + +bool serializeServersDat(const QString& filename, nbt::tag_compound* root) +{ + try { + if (!FS::ensureFilePathExists(filename)) { + return false; + } + std::ostringstream stream; + nbt::io::write_tag("", *root, stream); + const QByteArray bytes(stream.str().data(), + static_cast(stream.str().size())); + FS::write(filename, bytes); + return true; + } catch (...) { + return false; + } +} +} // namespace + +ServersListModel::ServersListModel(QString gameRoot, QObject* parent) + : QAbstractListModel(parent), m_gameRoot(std::move(gameRoot)) +{ + m_watcher = new QFileSystemWatcher(this); + connect(m_watcher, &QFileSystemWatcher::directoryChanged, this, + &ServersListModel::directoryChanged); + + m_saveTimer.setSingleShot(true); + // Same five-second debounce ServersPage's ServersModel used. + m_saveTimer.setInterval(5000); + connect(&m_saveTimer, &QTimer::timeout, this, &ServersListModel::saveNow); +} + +ServersListModel::~ServersListModel() +{ + saveNow(); +} + +QString ServersListModel::serversPath() const +{ + return QFileInfo(FS::PathCombine(m_gameRoot, "servers.dat")).filePath(); +} + +void ServersListModel::load() +{ + cancelSave(); + beginResetModel(); + QList servers; + if (auto root = parseServersDat(serversPath())) { + if (root->has_key("servers", nbt::tag_type::List)) { + auto& list = root->at("servers").as(); + for (auto& entry : list) { + auto& compound = entry.as(); + ServerEntry server; + try { + std::string address(compound["ip"]); + server.address = QString::fromUtf8(address.c_str()); + std::string name(compound["name"]); + server.name = QString::fromUtf8(name.c_str()); + } catch (...) { + continue; + } + if (compound.has_key("acceptTextures", nbt::tag_type::Byte)) { + const bool always = + compound["acceptTextures"].as().get(); + server.acceptTextures = always ? 1 : 2; + } + servers.append(server); + } + } + } + m_servers.swap(servers); + m_loaded = true; + endResetModel(); +} + +void ServersListModel::saveNow() +{ + cancelSave(); + if (!m_loaded) { + // Never overwrite a file this model has not actually read yet. + return; + } + nbt::tag_compound root; + nbt::tag_list list; + for (const auto& server : m_servers) { + nbt::tag_compound entry; + entry.insert("name", server.name.trimmed().toUtf8().toStdString()); + entry.insert("ip", server.address.trimmed().toUtf8().toStdString()); + if (server.acceptTextures != 0) { + entry.insert("acceptTextures", + nbt::tag_byte(server.acceptTextures == 1)); + } + list.push_back(std::move(entry)); + } + root.insert("servers", nbt::value(std::move(list))); + + if (!serializeServersDat(serversPath(), &root)) { + qWarning() << "ServersListModel: failed to save" << serversPath() + << "- will retry"; + scheduleSave(); + } +} + +void ServersListModel::scheduleSave() +{ + m_dirty = true; + m_saveTimer.start(); +} + +void ServersListModel::cancelSave() +{ + m_dirty = false; + m_saveTimer.stop(); +} + +void ServersListModel::updateFsWatch() +{ + // Mirrors ServersModel::updateFSObserver(): watch only while the page is + // open AND the instance is running (i.e. editing is refused) - never + // while the user could be mid-edit, so an external directory change can + // never clobber unsaved work. + const bool watching = m_watcher->directories().contains(m_gameRoot); + if (m_observed && m_locked) { + if (!watching) { + m_watcher->addPath(m_gameRoot); + } + } else if (watching) { + m_watcher->removePath(m_gameRoot); + } +} + +void ServersListModel::startWatching() +{ + if (m_observed) { + return; + } + m_observed = true; + if (!m_loaded) { + load(); + } + updateFsWatch(); +} + +void ServersListModel::stopWatching() +{ + if (!m_observed) { + return; + } + m_observed = false; + saveNow(); + updateFsWatch(); +} + +void ServersListModel::directoryChanged(const QString&) +{ + // A launch writes servers.dat itself (joining a server adds it back to + // the top of the list) - reload rather than clobber that with whatever + // this model still has queued. + load(); +} + +void ServersListModel::setLocked(bool locked) +{ + if (m_locked == locked) { + return; + } + m_locked = locked; + if (m_locked) { + saveNow(); + } + updateFsWatch(); + emit lockedChanged(); +} + +QVariant ServersListModel::data(const QModelIndex& index, int role) const +{ + const int row = index.row(); + if (row < 0 || row >= m_servers.size()) { + return {}; + } + const auto& server = m_servers.at(row); + switch (role) { + case NameRole: + case Qt::DisplayRole: + return server.name; + case AddressRole: + return server.address; + case AcceptTexturesRole: + return server.acceptTextures; + default: + return {}; + } +} + +int ServersListModel::rowCount(const QModelIndex& parent) const +{ + if (parent.isValid()) { + return 0; + } + return m_servers.size(); +} + +QHash ServersListModel::roleNames() const +{ + return { + { NameRole, "name" }, + { AddressRole, "address" }, + { AcceptTexturesRole, "acceptTextures" }, + }; +} + +int ServersListModel::addServer() +{ + if (m_locked) { + return -1; + } + if (!m_loaded) { + load(); + } + const int row = m_servers.size(); + beginInsertRows(QModelIndex(), row, row); + ServerEntry server; + server.name = tr("Minecraft Server"); + m_servers.append(server); + endInsertRows(); + scheduleSave(); + return row; +} + +bool ServersListModel::removeServer(int row) +{ + if (m_locked || row < 0 || row >= m_servers.size()) { + return false; + } + beginRemoveRows(QModelIndex(), row, row); + m_servers.removeAt(row); + endRemoveRows(); + scheduleSave(); + return true; +} + +bool ServersListModel::moveUp(int row) +{ + if (m_locked || row <= 0 || row >= m_servers.size()) { + return false; + } + beginMoveRows(QModelIndex(), row, row, QModelIndex(), row - 1); + m_servers.swapItemsAt(row - 1, row); + endMoveRows(); + scheduleSave(); + return true; +} + +bool ServersListModel::moveDown(int row) +{ + if (m_locked || row < 0 || row + 1 >= m_servers.size()) { + return false; + } + beginMoveRows(QModelIndex(), row, row, QModelIndex(), row + 2); + m_servers.swapItemsAt(row + 1, row); + endMoveRows(); + scheduleSave(); + return true; +} + +void ServersListModel::setName(int row, const QString& name) +{ + if (m_locked || row < 0 || row >= m_servers.size()) { + return; + } + if (m_servers[row].name == name) { + return; + } + m_servers[row].name = name; + emit dataChanged(index(row), index(row), { NameRole, Qt::DisplayRole }); + scheduleSave(); +} + +void ServersListModel::setAddress(int row, const QString& address) +{ + if (m_locked || row < 0 || row >= m_servers.size()) { + return; + } + if (m_servers[row].address == address) { + return; + } + m_servers[row].address = address; + emit dataChanged(index(row), index(row), { AddressRole }); + scheduleSave(); +} + +void ServersListModel::setAcceptTextures(int row, int mode) +{ + if (m_locked || row < 0 || row >= m_servers.size() || mode < 0 || + mode > 2) { + return; + } + if (m_servers[row].acceptTextures == mode) { + return; + } + m_servers[row].acceptTextures = mode; + emit dataChanged(index(row), index(row), { AcceptTexturesRole }); + scheduleSave(); +} + +QString ServersListModel::addressOf(int row) const +{ + if (row < 0 || row >= m_servers.size()) { + return {}; + } + return m_servers.at(row).address; +} diff --git a/launcher/models/ServersListModel.h b/launcher/models/ServersListModel.h new file mode 100644 index 00000000..754e015f --- /dev/null +++ b/launcher/models/ServersListModel.h @@ -0,0 +1,126 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include + +class QFileSystemWatcher; + +/* + * QML-facing list of one instance's servers.dat - the widget-free + * replacement for ServersPage's private ServersModel. + * + * Same on-disk format and save-debounce behaviour as the widget version: + * NBT, saved five seconds after the last edit (or immediately on + * destruction/lock), reloaded whenever the directory changes underneath it + * (multiplayer.txt written by another launcher, or the game itself while + * running). Locked (edits refused) while the instance is running, the same + * rule ServersPage applied - the game already has the file open. + * + * No icon decoding: servers.dat carries a base64 favicon the server itself + * sent on ping, but nothing here lets the user set one by hand (the widget + * page did not either - only "accept textures" was ever user-editable), so + * this model does not expose it. Every row shows the same generic glyph. + */ +class ServersListModel : public QAbstractListModel +{ + Q_OBJECT + + Q_PROPERTY(bool locked READ locked NOTIFY lockedChanged) + + public: + enum Roles { + NameRole = Qt::UserRole + 1, + AddressRole, + /// 0 = ask, 1 = always, 2 = never - same values as the widget's + /// Server::AcceptsTextures enum, so a QML combo box can index + /// straight into it. + AcceptTexturesRole, + }; + + /// @p gameRoot: the instance's gameRoot() - servers.dat lives directly + /// under it, same path ServersPage used. + explicit ServersListModel(QString gameRoot, QObject* parent = nullptr); + ~ServersListModel() override; + + QVariant data(const QModelIndex& index, + int role = Qt::DisplayRole) const override; + int rowCount(const QModelIndex& parent = QModelIndex()) const override; + QHash roleNames() const override; + + bool locked() const + { + return m_locked; + } + /// Called by InstanceDetails when the instance's running state changes. + void setLocked(bool locked); + + /// Starts/stops watching servers.dat's directory for external changes + /// and, on stop, flushes any pending save - mirrors ServersModel:: + /// observe()/unobserve() and BackupPage-style lifetime management. + void startWatching(); + void stopWatching(); + + /// Appends an empty server ("Minecraft Server", no address) and + /// returns its row, or -1 while locked. + Q_INVOKABLE int addServer(); + Q_INVOKABLE bool removeServer(int row); + Q_INVOKABLE bool moveUp(int row); + Q_INVOKABLE bool moveDown(int row); + Q_INVOKABLE void setName(int row, const QString& name); + Q_INVOKABLE void setAddress(int row, const QString& address); + /// @p mode: 0/1/2, see AcceptTexturesRole. + Q_INVOKABLE void setAcceptTextures(int row, int mode); + /// The address to join, or empty if @p row is out of range or has no + /// address set yet - callers should refuse to join in that case. + Q_INVOKABLE QString addressOf(int row) const; + + signals: + void lockedChanged(); + + private slots: + void directoryChanged(const QString& path); + + private: + struct ServerEntry { + QString name; + QString address; + int acceptTextures = 0; + }; + + void load(); + void saveNow(); + void scheduleSave(); + void cancelSave(); + QString serversPath() const; + void updateFsWatch(); + + QString m_gameRoot; + QList m_servers; + bool m_loaded = false; + bool m_locked = false; + bool m_observed = false; + bool m_dirty = false; + QFileSystemWatcher* m_watcher = nullptr; + QTimer m_saveTimer; +}; diff --git a/launcher/models/ServersListModel_test.cpp b/launcher/models/ServersListModel_test.cpp new file mode 100644 index 00000000..1387bbb2 --- /dev/null +++ b/launcher/models/ServersListModel_test.cpp @@ -0,0 +1,225 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include +#include +#include + +#include "models/ServersListModel.h" + +/* Needs neither a BaseInstance nor a LauncherContext - only a directory to + * read/write servers.dat under, same reason OtherLogsModel_test.cpp drives + * that model directly against a temp directory instead of a real + * instance. */ +class ServersListModelTest : public QObject +{ + Q_OBJECT + + private slots: + void addEditMoveRemove(); + void persistsAcrossReload(); + void lockedRefusesEdits(); + void watchesDirectoryOnlyWhileLocked(); + void addressOfOutOfRange(); +}; + +void ServersListModelTest::addEditMoveRemove() +{ + QTemporaryDir dir; + QVERIFY(dir.isValid()); + + ServersListModel model(dir.path()); + model.startWatching(); + QCOMPARE(model.rowCount(), 0); + + const int first = model.addServer(); + QCOMPARE(first, 0); + QCOMPARE(model.rowCount(), 1); + QCOMPARE(model.data(model.index(0), ServersListModel::NameRole).toString(), + QStringLiteral("Minecraft Server")); + + model.setName(0, QStringLiteral("Home")); + model.setAddress(0, QStringLiteral("home.example.com:25565")); + model.setAcceptTextures(0, 1); + + const int second = model.addServer(); + QCOMPARE(second, 1); + model.setName(1, QStringLiteral("Away")); + model.setAddress(1, QStringLiteral("away.example.com")); + + QCOMPARE(model.data(model.index(0), ServersListModel::NameRole).toString(), + QStringLiteral("Home")); + QCOMPARE( + model.data(model.index(0), ServersListModel::AddressRole).toString(), + QStringLiteral("home.example.com:25565")); + QCOMPARE( + model.data(model.index(0), ServersListModel::AcceptTexturesRole).toInt(), + 1); + + // Move "Away" (row 1) up in front of "Home". + QVERIFY(model.moveUp(1)); + QCOMPARE(model.data(model.index(0), ServersListModel::NameRole).toString(), + QStringLiteral("Away")); + QCOMPARE(model.data(model.index(1), ServersListModel::NameRole).toString(), + QStringLiteral("Home")); + + QCOMPARE(model.addressOf(1), QStringLiteral("home.example.com:25565")); + + QVERIFY(model.removeServer(0)); + QCOMPARE(model.rowCount(), 1); + QCOMPARE(model.data(model.index(0), ServersListModel::NameRole).toString(), + QStringLiteral("Home")); +} + +void ServersListModelTest::persistsAcrossReload() +{ + QTemporaryDir dir; + QVERIFY(dir.isValid()); + + { + ServersListModel model(dir.path()); + model.startWatching(); + model.addServer(); + model.setName(0, QStringLiteral("Persisted")); + model.setAddress(0, QStringLiteral("persisted.example.com:1234")); + model.setAcceptTextures(0, 2); + model.stopWatching(); + // stopWatching() flushes the pending save synchronously - the + // destructor below would too, but this checks that specifically. + } + + QVERIFY(QFile::exists(dir.filePath(QStringLiteral("servers.dat")))); + + ServersListModel reloaded(dir.path()); + reloaded.startWatching(); + QCOMPARE(reloaded.rowCount(), 1); + QCOMPARE( + reloaded.data(reloaded.index(0), ServersListModel::NameRole).toString(), + QStringLiteral("Persisted")); + QCOMPARE(reloaded.data(reloaded.index(0), ServersListModel::AddressRole) + .toString(), + QStringLiteral("persisted.example.com:1234")); + QCOMPARE(reloaded + .data(reloaded.index(0), + ServersListModel::AcceptTexturesRole) + .toInt(), + 2); +} + +void ServersListModelTest::lockedRefusesEdits() +{ + QTemporaryDir dir; + QVERIFY(dir.isValid()); + + ServersListModel model(dir.path()); + model.startWatching(); + model.addServer(); + model.setName(0, QStringLiteral("Original")); + + QSignalSpy lockedSpy(&model, &ServersListModel::lockedChanged); + model.setLocked(true); + QCOMPARE(lockedSpy.count(), 1); + QVERIFY(model.locked()); + + // Every mutation is a no-op while locked. + QCOMPARE(model.addServer(), -1); + model.setName(0, QStringLiteral("Changed")); + model.setAddress(0, QStringLiteral("changed.example.com")); + QVERIFY(!model.removeServer(0)); + QCOMPARE(model.rowCount(), 1); + QCOMPARE(model.data(model.index(0), ServersListModel::NameRole).toString(), + QStringLiteral("Original")); + + model.setLocked(false); + QVERIFY(!model.locked()); + model.setName(0, QStringLiteral("Changed")); + QCOMPARE(model.data(model.index(0), ServersListModel::NameRole).toString(), + QStringLiteral("Changed")); +} + +void ServersListModelTest::watchesDirectoryOnlyWhileLocked() +{ + QTemporaryDir dir; + QVERIFY(dir.isValid()); + + ServersListModel model(dir.path()); + model.startWatching(); + model.addServer(); + model.setName(0, QStringLiteral("Unsaved edit")); + + QSignalSpy resetSpy(&model, &QAbstractItemModel::modelReset); + + // Unlocked and observed: the user may be mid-edit, so a change in the + // directory (here a stray file, standing in for the game or another + // launcher touching it) must not reload the list underneath them. The + // widget's ServersModel watched only while locked for this reason. + { + QFile stray(dir.filePath(QStringLiteral("stray-1.txt"))); + QVERIFY(stray.open(QIODevice::WriteOnly)); + stray.write("x"); + } + QTest::qWait(500); + QCOMPARE(resetSpy.count(), 0); + QCOMPARE(model.rowCount(), 1); + QCOMPARE(model.data(model.index(0), ServersListModel::NameRole).toString(), + QStringLiteral("Unsaved edit")); + + // Locking (the game started) flushes the edit to disk and starts the + // watch: from here an external change is picked up, as a positive + // control that the watch really works in this environment. + model.setLocked(true); + { + QFile stray(dir.filePath(QStringLiteral("stray-2.txt"))); + QVERIFY(stray.open(QIODevice::WriteOnly)); + stray.write("x"); + } + QTRY_VERIFY_WITH_TIMEOUT(resetSpy.count() >= 1, 5000); + QCOMPARE(model.rowCount(), 1); + QCOMPARE(model.data(model.index(0), ServersListModel::NameRole).toString(), + QStringLiteral("Unsaved edit")); + + // Unlocking (the game closed) drops the watch again. + model.setLocked(false); + // Let anything the engine had already queued from the locked phase + // arrive before taking the baseline. + QTest::qWait(300); + const int resetsBefore = resetSpy.count(); + { + QFile stray(dir.filePath(QStringLiteral("stray-3.txt"))); + QVERIFY(stray.open(QIODevice::WriteOnly)); + stray.write("x"); + } + QTest::qWait(500); + QCOMPARE(resetSpy.count(), resetsBefore); +} + +void ServersListModelTest::addressOfOutOfRange() +{ + QTemporaryDir dir; + QVERIFY(dir.isValid()); + + ServersListModel model(dir.path()); + model.startWatching(); + QCOMPARE(model.addressOf(-1), QString()); + QCOMPARE(model.addressOf(0), QString()); +} + +QTEST_GUILESS_MAIN(ServersListModelTest) +#include "ServersListModel_test.moc" diff --git a/launcher/models/SettingsAdapter.cpp b/launcher/models/SettingsAdapter.cpp new file mode 100644 index 00000000..0690790e --- /dev/null +++ b/launcher/models/SettingsAdapter.cpp @@ -0,0 +1,134 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "SettingsAdapter.h" + +#include + +#include "core/LauncherContext.h" +#include "settings/Setting.h" +#include "tools/JProfiler.h" +#include "tools/JVisualVM.h" +#include "tools/MCEditTool.h" + +SettingsAdapter::SettingsAdapter(SettingsObjectPtr settings, QObject* parent) + : QObject(parent), m_settings(std::move(settings)) +{ + if (!m_settings) { + return; + } + + connect(m_settings.get(), &SettingsObject::SettingChanged, this, + [this](const Setting& setting, QVariant value) { + emit valueChanged(setting.id(), value); + }); + // Resetting removes the stored value, so the effective value becomes + // the default again -- report that, not an invalid QVariant. + connect(m_settings.get(), &SettingsObject::settingReset, this, + [this](const Setting& setting) { + emit valueChanged(setting.id(), setting.get()); + }); +} + +QVariant SettingsAdapter::value(const QString& id) const +{ + if (!m_settings) { + return QVariant(); + } + return m_settings->get(id); +} + +QVariant SettingsAdapter::defaultValue(const QString& id) const +{ + if (!m_settings) { + return QVariant(); + } + auto setting = m_settings->getSetting(id); + return setting ? setting->defValue() : QVariant(); +} + +bool SettingsAdapter::contains(const QString& id) const +{ + return m_settings && m_settings->contains(id); +} + +void SettingsAdapter::setValue(const QString& id, const QVariant& value) +{ + if (!m_settings) { + return; + } + auto setting = m_settings->getSetting(id); + if (!setting) { + qWarning() << "SettingsAdapter::setValue: unknown setting id" << id; + return; + } + + QVariant converted = value; + QVariant defVal = setting->defValue(); + // Only convert against a default that actually pins down a type -- + // settings registered with no default (an invalid QVariant) have + // nothing to convert to, so whatever QML sent is stored as-is. + if (defVal.isValid() && converted.isValid() && + converted.metaType() != defVal.metaType() && + !converted.convert(defVal.metaType())) { + qWarning() << "SettingsAdapter::setValue: could not convert value" + << "for" << id << "to" << defVal.typeName(); + return; + } + + m_settings->set(id, converted); +} + +void SettingsAdapter::reset(const QString& id) +{ + if (!m_settings) { + return; + } + m_settings->reset(id); +} + +void SettingsAdapter::applyProxySettings(const QString& proxyType, + const QString& addr, int port, + const QString& user, + const QString& password) +{ + if (!LAUNCHER) { + return; + } + LAUNCHER->updateProxySettings(proxyType, addr, port, user, password); +} + +QString SettingsAdapter::checkExternalTool(const QString& tool, + const QString& path) const +{ + QString error; + bool ok = false; + if (tool == QLatin1String("jprofiler")) { + ok = JProfilerFactory().check(path, &error); + } else if (tool == QLatin1String("jvisualvm")) { + ok = JVisualVMFactory().check(path, &error); + } else if (tool == QLatin1String("mcedit")) { + ok = m_settings && MCEditTool(m_settings).check(path, error); + } else { + qWarning() << "SettingsAdapter::checkExternalTool: unknown tool" + << tool; + return tr("Unknown tool."); + } + return ok ? QString() : error; +} diff --git a/launcher/models/SettingsAdapter.h b/launcher/models/SettingsAdapter.h new file mode 100644 index 00000000..8e838f63 --- /dev/null +++ b/launcher/models/SettingsAdapter.h @@ -0,0 +1,103 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include + +#include "settings/SettingsObject.h" + +/* + * QML-facing wrapper around a SettingsObject. QML cannot hold a + * std::shared_ptr or call into Setting/SettingsObject directly (they are not + * Q_INVOKABLE-friendly and the settings id is looked up by string from the + * page, not by a Setting pointer held on the QML side), so this is the one + * object a settings page binds against. + * + * Values coming back from QML are always JS values: numbers arrive as + * double, so an int setting (e.g. a memory slider) would otherwise get + * stored as "4096.0" instead of "4096". setValue() converts the incoming + * QVariant to the type of the setting's registered default value before + * handing it to SettingsObject, so the config file keeps the type it always + * had. + * + * Being the one object every QML settings page already binds against also + * makes it the natural place for the couple of settings-adjacent actions + * that are not plain config values -- applyProxySettings() and + * checkExternalTool() below -- rather than inventing a second QML-facing + * object solely to reach them. + * + * Core, like the rest of models/: QtCore only, no QtWidgets, no ui/. + */ +class SettingsAdapter : public QObject +{ + Q_OBJECT + + public: + explicit SettingsAdapter(SettingsObjectPtr settings, + QObject* parent = nullptr); + + /// Current value of setting @p id, or an invalid QVariant if @p id is + /// unknown. + Q_INVOKABLE QVariant value(const QString& id) const; + /// Registered default value of setting @p id, or an invalid QVariant if + /// @p id is unknown. + Q_INVOKABLE QVariant defaultValue(const QString& id) const; + /// Whether a setting with this id is registered. + Q_INVOKABLE bool contains(const QString& id) const; + /// Stores @p value for setting @p id, converted to the type of that + /// setting's default value first. No-op (with a warning) if @p id is + /// unknown. + Q_INVOKABLE void setValue(const QString& id, const QVariant& value); + /// Reverts setting @p id to its registered default. + Q_INVOKABLE void reset(const QString& id); + + /*! + * Applies a proxy configuration to the whole application immediately -- + * the same effect ProxyPage::apply() has under the classic settings + * dialog. Needed alongside plain setValue(): the QML Proxy section saves + * each field as it is typed, with no separate "Apply" step, so the live + * proxy has to be pushed explicitly whenever one of its fields changes. + * @p proxyType is one of "None", "Default", "SOCKS5", "HTTP". + */ + Q_INVOKABLE void applyProxySettings(const QString& proxyType, + const QString& addr, int port, + const QString& user, + const QString& password); + + /*! + * Checks whether @p path looks like a working install of @p tool + * ("jprofiler", "jvisualvm" or "mcedit") -- the same check the classic + * External Tools page's "Check" buttons run. Returns an empty string if + * it looks fine, or a user-facing reason it does not. + */ + Q_INVOKABLE QString checkExternalTool(const QString& tool, + const QString& path) const; + + signals: + /// Emitted whenever a wrapped setting's effective value changes, + /// whether from setValue(), reset(), or anything else that changes the + /// underlying SettingsObject (another page, a plugin, ...). + void valueChanged(const QString& id, const QVariant& value); + + private: + SettingsObjectPtr m_settings; +}; diff --git a/launcher/models/SettingsAdapter_test.cpp b/launcher/models/SettingsAdapter_test.cpp new file mode 100644 index 00000000..9e4283f9 --- /dev/null +++ b/launcher/models/SettingsAdapter_test.cpp @@ -0,0 +1,197 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include +#include + +#include "models/SettingsAdapter.h" +#include "settings/INISettingsObject.h" + +namespace +{ + /// A fresh INISettingsObject backed by a file in a scratch temp + /// directory, with a handful of settings of different types + /// registered -- enough to exercise conversion on setValue(). + SettingsObjectPtr makeSettings(QTemporaryDir& dir) + { + auto settings = std::make_shared( + dir.filePath("settings.ini")); + settings->registerSetting("IntSetting", 512); + settings->registerSetting("BoolSetting", false); + settings->registerSetting("StringSetting", QString("default")); + return settings; + } +} // namespace + +class SettingsAdapterTest : public QObject +{ + Q_OBJECT + + private slots: + void test_value_fallsBackToDefault_whenNeverSet() + { + QTemporaryDir dir; + SettingsAdapter adapter(makeSettings(dir)); + + QCOMPARE(adapter.value("IntSetting"), QVariant(512)); + QCOMPARE(adapter.defaultValue("IntSetting"), QVariant(512)); + } + + void test_value_unknownId_returnsInvalid() + { + QTemporaryDir dir; + SettingsAdapter adapter(makeSettings(dir)); + + QVERIFY(!adapter.value("NoSuchSetting").isValid()); + QVERIFY(!adapter.defaultValue("NoSuchSetting").isValid()); + } + + void test_contains_reflectsRegisteredSettings() + { + QTemporaryDir dir; + SettingsAdapter adapter(makeSettings(dir)); + + QVERIFY(adapter.contains("IntSetting")); + QVERIFY(!adapter.contains("NoSuchSetting")); + } + + /// The bug setValue() exists to prevent: QML hands an int setting a JS + /// number, which arrives as a double, and it must not be stored as + /// "4096.0". + void test_setValue_convertsDoubleToInt() + { + QTemporaryDir dir; + auto settings = makeSettings(dir); + SettingsAdapter adapter(settings); + + adapter.setValue("IntSetting", QVariant(4096.0)); + + QVariant stored = adapter.value("IntSetting"); + QCOMPARE(stored.typeId(), int(QMetaType::Int)); + QCOMPARE(stored.toInt(), 4096); + // Not just the adapter's own read path -- the underlying setting + // really holds an int, not a double. + QCOMPARE(settings->get("IntSetting").typeId(), int(QMetaType::Int)); + } + + void test_setValue_bool_andString() + { + QTemporaryDir dir; + SettingsAdapter adapter(makeSettings(dir)); + + adapter.setValue("BoolSetting", QVariant(true)); + QCOMPARE(adapter.value("BoolSetting"), QVariant(true)); + + adapter.setValue("StringSetting", QVariant("hello")); + QCOMPARE(adapter.value("StringSetting"), QVariant(QString("hello"))); + } + + void test_setValue_unknownId_isNoOp() + { + QTemporaryDir dir; + auto settings = makeSettings(dir); + SettingsAdapter adapter(settings); + + adapter.setValue("NoSuchSetting", QVariant(1)); + + QVERIFY(!settings->contains("NoSuchSetting")); + } + + void test_reset_revertsToDefault() + { + QTemporaryDir dir; + SettingsAdapter adapter(makeSettings(dir)); + adapter.setValue("IntSetting", QVariant(1024.0)); + QCOMPARE(adapter.value("IntSetting"), QVariant(1024)); + + adapter.reset("IntSetting"); + + QCOMPARE(adapter.value("IntSetting"), QVariant(512)); + } + + void test_valueChanged_emittedOnSetValue() + { + QTemporaryDir dir; + SettingsAdapter adapter(makeSettings(dir)); + QSignalSpy spy(&adapter, &SettingsAdapter::valueChanged); + + adapter.setValue("IntSetting", QVariant(2048.0)); + + QCOMPARE(spy.count(), 1); + QCOMPARE(spy.first().at(0).toString(), QString("IntSetting")); + QCOMPARE(spy.first().at(1).toInt(), 2048); + } + + void test_valueChanged_emittedOnReset() + { + QTemporaryDir dir; + SettingsAdapter adapter(makeSettings(dir)); + adapter.setValue("IntSetting", QVariant(2048.0)); + + QSignalSpy spy(&adapter, &SettingsAdapter::valueChanged); + adapter.reset("IntSetting"); + + QCOMPARE(spy.count(), 1); + QCOMPARE(spy.first().at(0).toString(), QString("IntSetting")); + QCOMPARE(spy.first().at(1).toInt(), 512); + } + + // No LauncherContext exists in this guiless test (see + // AccountsController_test.cpp's own comment on the same limitation) -- + // applyProxySettings() must not crash without one, it just has nothing + // to apply to. + void test_applyProxySettings_withoutLauncherContext_doesNotCrash() + { + QTemporaryDir dir; + SettingsAdapter adapter(makeSettings(dir)); + + adapter.applyProxySettings("SOCKS5", "127.0.0.1", 1080, "user", "pass"); + } + + void test_checkExternalTool_unknownTool_reportsAnError() + { + QTemporaryDir dir; + SettingsAdapter adapter(makeSettings(dir)); + + QVERIFY(!adapter.checkExternalTool("not-a-tool", "/some/path").isEmpty()); + } + + void test_checkExternalTool_emptyPath_reportsAnErrorPerTool() + { + QTemporaryDir dir; + SettingsAdapter adapter(makeSettings(dir)); + + QVERIFY(!adapter.checkExternalTool("jprofiler", "").isEmpty()); + QVERIFY(!adapter.checkExternalTool("jvisualvm", "").isEmpty()); + QVERIFY(!adapter.checkExternalTool("mcedit", "").isEmpty()); + } + + void test_checkExternalTool_mcedit_rejectsFolderWithoutMCEdit() + { + QTemporaryDir dir; + SettingsAdapter adapter(makeSettings(dir)); + + QVERIFY(!adapter.checkExternalTool("mcedit", dir.path()).isEmpty()); + } +}; + +QTEST_GUILESS_MAIN(SettingsAdapterTest) + +#include "SettingsAdapter_test.moc" diff --git a/launcher/models/WorldDataPacksController.cpp b/launcher/models/WorldDataPacksController.cpp new file mode 100644 index 00000000..e5329cf3 --- /dev/null +++ b/launcher/models/WorldDataPacksController.cpp @@ -0,0 +1,150 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "WorldDataPacksController.h" + +#include +#include + +#include "BaseInstance.h" +#include "FileSystem.h" +#include "minecraft/MinecraftInstance.h" +#include "minecraft/WorldList.h" +#include "minecraft/mod/DataPackFolderModel.h" + +WorldDataPacksController::WorldDataPacksController(MinecraftInstance* instance, + WorldList* worlds, + QObject* parent) + : QObject(parent), m_instance(instance), m_worlds(worlds) +{ + if (m_instance) { + connect(m_instance, &BaseInstance::runningStatusChanged, this, + &WorldDataPacksController::onRunningStatusChanged); + } +} + +WorldDataPacksController::~WorldDataPacksController() +{ + if (m_model) { + m_model->stopWatching(); + } +} + +QObject* WorldDataPacksController::model() const +{ + return m_sorted.get(); +} + +QString WorldDataPacksController::directory() const +{ + return m_model ? m_model->dir().absolutePath() : QString(); +} + +bool WorldDataPacksController::unlocked() const +{ + return m_instance && !m_instance->isRunning(); +} + +void WorldDataPacksController::onRunningStatusChanged() +{ + emit unlockedChanged(); +} + +void WorldDataPacksController::openForWorld(int row) +{ + if (!m_worlds || row < 0 || + static_cast(row) >= m_worlds->size()) { + return; + } + + auto& world = (*m_worlds)[static_cast(row)]; + const QString worldDir = m_worlds->dir().absoluteFilePath(world.folderName()); + const QString folder = FS::PathCombine(worldDir, "datapacks"); + const QString name = + world.name().isEmpty() ? world.folderName() : world.name(); + + if (m_model && QDir(m_model->dir().absolutePath()) == QDir(folder)) { + // Already showing this world - the name may still have changed. + m_worldName = name; + emit modelChanged(); + return; + } + + if (m_model) { + m_model->stopWatching(); + } + + m_worldName = name; + m_model = std::make_unique(folder); + m_sorted = std::make_unique(); + m_sorted->setSourceModel(m_model.get()); + m_sorted->setSortRole(m_model->roleNames().key("name", Qt::DisplayRole)); + m_sorted->setSortCaseSensitivity(Qt::CaseInsensitive); + m_sorted->setDynamicSortFilter(true); + m_sorted->sort(0); + + // Mirrors InstanceDetails.cpp's startWatchingFresh(): a folder that + // already exists (this world had data packs before) needs an explicit + // update() too, since startWatching() only runs one on its very first + // call ever for a given QFileSystemWatcher path. + const bool wasValid = m_model->isValid() && m_model->dir().exists(); + m_model->startWatching(); + if (wasValid) { + m_model->update(); + } + + emit modelChanged(); +} + +void WorldDataPacksController::setEnabled(int row, bool enabled) +{ + if (!m_model || !m_sorted || row < 0 || row >= m_sorted->rowCount()) { + return; + } + const QModelIndex source = + m_sorted->mapToSource(m_sorted->index(row, 0)); + if (!source.isValid()) { + return; + } + m_model->setModStatus({ source }, enabled ? ModFolderModel::Enable + : ModFolderModel::Disable); +} + +void WorldDataPacksController::remove(int row) +{ + if (!m_model || !m_sorted || row < 0 || row >= m_sorted->rowCount()) { + return; + } + const QModelIndex source = + m_sorted->mapToSource(m_sorted->index(row, 0)); + if (!source.isValid()) { + return; + } + m_model->deleteMods({ source }); +} + +bool WorldDataPacksController::install(const QString& fileUrlOrPath) +{ + if (!m_model) { + return false; + } + const QUrl url(fileUrlOrPath); + const QString path = url.isLocalFile() ? url.toLocalFile() : fileUrlOrPath; + return m_model->installMod(path); +} diff --git a/launcher/models/WorldDataPacksController.h b/launcher/models/WorldDataPacksController.h new file mode 100644 index 00000000..375b8fb0 --- /dev/null +++ b/launcher/models/WorldDataPacksController.h @@ -0,0 +1,104 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include + +class MinecraftInstance; +class WorldList; +class DataPackFolderModel; + +/* + * QML-facing data pack manager for one world's saves/<world>/datapacks + * folder - the widget-free replacement for the modal dialog + * WorldListPage::on_actionDatapacks_triggered() used to open. + * + * Holds at most one world's folder model at a time, rebuilt whenever + * openForWorld() names a different world - same "ask again for the same one, + * get the same object back; ask for a different one and the old one goes + * away" shape as InstanceDetails::instanceDetails() and QmlShell:: + * sectionModel(). Created lazily by InstanceDetails::worldDataPacks() and + * parented to it, so opening the instance page never watches a world's + * folder until the Data packs tab actually picks one. + */ +class WorldDataPacksController : public QObject +{ + Q_OBJECT + + /// Sorted-by-name proxy over the current world's DataPackFolderModel + /// (ModFolderModel's usual name/version/enabled roles) - null until + /// openForWorld() has been called with a valid row. + Q_PROPERTY(QObject* model READ model NOTIFY modelChanged) + Q_PROPERTY(QString worldName READ worldName NOTIFY modelChanged) + Q_PROPERTY(QString directory READ directory NOTIFY modelChanged) + Q_PROPERTY(bool ready READ ready NOTIFY modelChanged) + /// False while the instance is running - same rule InstanceDetails:: + /// contentChangesAllowed() applies to every other folder-backed + /// content type. + Q_PROPERTY(bool unlocked READ unlocked NOTIFY unlockedChanged) + + public: + explicit WorldDataPacksController(MinecraftInstance* instance, + WorldList* worlds, + QObject* parent = nullptr); + ~WorldDataPacksController() override; + + QObject* model() const; + QString worldName() const + { + return m_worldName; + } + QString directory() const; + bool ready() const + { + return m_model != nullptr; + } + bool unlocked() const; + + /// Points this controller at world @p row's datapacks folder, + /// rebuilding the model if that is not already what it shows. A no-op + /// for an out-of-range row (model() then stays whatever it was). + Q_INVOKABLE void openForWorld(int row); + Q_INVOKABLE void setEnabled(int row, bool enabled); + Q_INVOKABLE void remove(int row); + /// @p fileUrlOrPath: a file:// URL (as a QML FileDialog hands out) or + /// a plain local path. + Q_INVOKABLE bool install(const QString& fileUrlOrPath); + + signals: + void modelChanged(); + void unlockedChanged(); + + private slots: + void onRunningStatusChanged(); + + private: + /// Borrowed, like InstanceDetails::m_mc - valid for this controller's + /// whole lifetime (parented to the InstanceDetails that owns both). + MinecraftInstance* m_instance; + WorldList* m_worlds; + + QString m_worldName; + std::unique_ptr m_model; + std::unique_ptr m_sorted; +}; diff --git a/launcher/modplatform/BlockedMod.h b/launcher/modplatform/BlockedMod.h new file mode 100644 index 00000000..c588a2cc --- /dev/null +++ b/launcher/modplatform/BlockedMod.h @@ -0,0 +1,39 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +/* + * A pack file the provider's API refuses to hand out, which the user has to + * fetch by hand. + * + * `found` is written by whoever is watching the download folder, so this + * travels from the install task out to the user interface and back with the + * answer filled in. It used to be declared inside BlockedModsDialog.h, which + * meant the install task included a QDialog header to describe its own data. + */ +struct BlockedMod { + int projectId; + int fileId; + QString fileName; + QString targetPath; + bool found = false; +}; diff --git a/launcher/modplatform/ContentDownloadTask.cpp b/launcher/modplatform/ContentDownloadTask.cpp index 119257cf..23df7fd4 100644 --- a/launcher/modplatform/ContentDownloadTask.cpp +++ b/launcher/modplatform/ContentDownloadTask.cpp @@ -18,7 +18,7 @@ */ #include "ContentDownloadTask.h" -#include "Application.h" +#include "core/LauncherContext.h" #include "minecraft/mod/ModMetadataIndex.h" #include "net/Download.h" #include "net/ChecksumValidator.h" @@ -77,7 +77,7 @@ void ContentDownloadTask::executeTask() setStatus(tr("Downloading %1 file(s)...").arg(m_items.size())); - m_netJob = new NetJob("ContentDownload", APPLICATION->network()); + m_netJob = new NetJob("ContentDownload", LAUNCHER->network()); int skipped = 0; // Last-resort safety net: even if an upstream stage (dependency diff --git a/launcher/modplatform/ContentProviderModel.cpp b/launcher/modplatform/ContentProviderModel.cpp index e7bee33c..106726d4 100644 --- a/launcher/modplatform/ContentProviderModel.cpp +++ b/launcher/modplatform/ContentProviderModel.cpp @@ -24,11 +24,10 @@ #include #include -#include "Application.h" +#include "core/LauncherContext.h" #include "minecraft/mod/ModMetadataIndex.h" #include "net/Download.h" #include "net/HttpMetaCache.h" -#include "ui/widgets/ProjectItemDelegate.h" ContentProviderModel::ContentProviderModel(const ModPlatform::ContentApi& api, ModPlatform::ContentType contentType, @@ -90,7 +89,7 @@ QVariant ContentProviderModel::data(const QModelIndex& index, int role) const } const_cast(this)->requestLogo( project.logoKey, project.logoUrl); - return APPLICATION->getThemedIcon("screenshot-placeholder"); + return LAUNCHER->getThemedIcon("screenshot-placeholder"); } case Qt::SizeHintRole: @@ -126,6 +125,12 @@ QVariant ContentProviderModel::data(const QModelIndex& index, int role) const case LogoKeyRole: return project.logoKey; + case LogoUrlRole: + return project.logoUrl; + + case AuthorRole: + return project.author; + default: break; } @@ -141,6 +146,8 @@ QHash ContentProviderModel::roleNames() const roles.insert(ProjectItemRole::Description, "description"); roles.insert(ProjectItemRole::Installed, "installed"); roles.insert(LogoKeyRole, "logoKey"); + roles.insert(LogoUrlRole, "logoUrl"); + roles.insert(AuthorRole, "author"); return roles; } @@ -195,7 +202,7 @@ void ContentProviderModel::loadCategories() auto response = std::make_shared(); auto* job = new NetJob(QString("%1::Categories").arg(m_api.id()), - APPLICATION->network()); + LAUNCHER->network()); job->addNetAction(Net::Download::makeByteArray( m_api.categoriesUrl(m_contentType), response.get())); @@ -341,6 +348,7 @@ void ContentProviderModel::restartSearch() m_generation++; m_searchState = None; m_nextSearchOffset = 0; + m_lastError.clear(); performPaginatedSearch(); } @@ -366,15 +374,17 @@ void ContentProviderModel::performPaginatedSearch() .arg(m_api.id(), m_projectLookupId); } - auto* job = new NetJob(jobName, APPLICATION->network()); + auto* job = new NetJob(jobName, LAUNCHER->network()); job->addNetAction( Net::Download::makeByteArray(url, &m_searchResponse)); m_searchJob = job; connect(job, &NetJob::succeeded, this, &ContentProviderModel::searchRequestFinished); - connect(job, &NetJob::failed, this, - [this](QString) { searchRequestFailed(); }); + connect(job, &NetJob::failed, this, [this](QString reason) { + m_lastError = reason; + searchRequestFailed(); + }); job->start(); emit searchStateChanged(); @@ -391,6 +401,8 @@ void ContentProviderModel::searchRequestFinished() return; } + m_lastError.clear(); + int totalHits = -1; QList newList; if (m_projectLookupId.isEmpty()) { @@ -504,7 +516,7 @@ void ContentProviderModel::loadEntry(int viewRow) auto response = std::make_shared(); auto* job = new NetJob(QString("%1::Versions(%2)") .arg(m_api.id(), projectId), - APPLICATION->network()); + LAUNCHER->network()); job->addNetAction(Net::Download::makeByteArray( m_api.projectVersionsUrl(query), response.get())); @@ -532,7 +544,7 @@ void ContentProviderModel::loadEntry(int viewRow) auto response = std::make_shared(); auto* job = new NetJob( QString("%1::Description(%2)").arg(m_api.id(), projectId), - APPLICATION->network()); + LAUNCHER->network()); job->addNetAction(Net::Download::makeByteArray( m_api.projectBodyUrl(projectId), response.get())); @@ -675,11 +687,11 @@ void ContentProviderModel::requestLogo(const QString& key, const QString& url) return; } - MetaEntryPtr entry = APPLICATION->metacache()->resolveEntry( + MetaEntryPtr entry = LAUNCHER->metacache()->resolveEntry( iconCacheName(), QString("logos/%1").arg(key.section(".", 0, 0))); auto* job = new NetJob(QString("%1 Icon %2").arg(m_api.id(), key), - APPLICATION->network()); + LAUNCHER->network()); job->addNetAction(Net::Download::makeCached(QUrl(url), entry)); const QString fullPath = entry->getFullPath(); diff --git a/launcher/modplatform/ContentProviderModel.h b/launcher/modplatform/ContentProviderModel.h index 1c2a6552..1db2d9d3 100644 --- a/launcher/modplatform/ContentProviderModel.h +++ b/launcher/modplatform/ContentProviderModel.h @@ -44,6 +44,11 @@ namespace ModPlatform QString versionId; /* Human-readable version, without decorations. */ QString name; + /* The version as a person reads it - "1.2.3" - kept apart from + * `name` above, which some providers (Modrinth) decorate with it + * in parentheses. Empty when the provider does not give a bare + * number of its own (CurseForge only has `name`). */ + QString versionNumber; /* "release" / "beta" / "alpha", empty when the provider does not * say. Shown in brackets after the name, like the reference * launcher does. */ @@ -57,6 +62,22 @@ namespace ModPlatform * so the review dialog can say so, and so the caller can offer * to fetch the file by hand if the download is refused. */ bool browserDownloadOnly = false; + + /* Minecraft versions and mod loaders this version declares + * support for. Populated from whatever the provider's version + * reply states - CurseForge mixes both kinds of tag into one + * "gameVersions" array (see ModPlatform::isKnownLoaderName()), + * Modrinth states them as two separate fields. Used to flag a + * version as compatible or not for a QML version picker; the + * provider's own search/version query is already asked to filter + * by these, but that filtering is not fully trusted elsewhere in + * this codebase either (see VersionPicker.cpp), so it is checked + * again on the client side. */ + QStringList gameVersions; + QStringList loaders; + /* RFC 3339 publish date, exactly as the provider states it; empty + * when unknown. */ + QString datePublished; }; /* One search result. Everything the list, the description pane and @@ -94,6 +115,31 @@ namespace ModPlatform } // namespace ModPlatform +/* Extra data roles this model exposes for ProjectItemDelegate + * (ui/widgets/ProjectItemDelegate.h) to paint a row. Declared here rather + * than on the delegate because these are roles the model's data() + * produces - a data model has no business depending on a view header. + * + * Qt::UserRole itself is already taken: both search models return the + * platform's project id there, and existing code reads it. These start + * one past it. + * + * Qt::DisplayRole is deliberately not reused for the title. The default + * delegate paints DisplayRole, so leaving it as the plain name keeps the + * list readable if a view is ever shown without this delegate attached. */ +namespace ProjectItemRole +{ + enum Role { + /* QString - project name, drawn large on the first line. */ + Title = Qt::UserRole + 1, + /* QString - short summary, wrapped over at most two lines. */ + Description, + /* bool - already present in the target folder. Such rows are + * dimmed and tagged, because installing them again is a no-op. */ + Installed, + }; +} + /* Search results for one content provider. * * There used to be two of these, one per provider, ninety percent @@ -111,10 +157,15 @@ class ContentProviderModel : public QAbstractListModel Q_OBJECT public: - /* Continues past ProjectItemRole (Qt::UserRole+1..+3, defined in - * ProjectItemDelegate.h), which the QtWidgets delegate already reads - * from this model's data(). */ - enum ModelRoles { LogoKeyRole = Qt::UserRole + 4 }; + /* Continues past ProjectItemRole (Qt::UserRole+1..+3, declared + * above), which ProjectItemDelegate already reads from this + * model's data(). */ + enum ModelRoles { + LogoKeyRole = Qt::UserRole + 4, + // For QML delegates, which load the logo themselves. + LogoUrlRole, + AuthorRole + }; ~ContentProviderModel() override; @@ -191,6 +242,18 @@ class ContentProviderModel : public QAbstractListModel return m_searchJob.get(); } + /* Why the most recent search (or page fetch) failed, or empty on + * success or while nothing has run yet. Cleared at the start of every + * new search and by a page fetch that succeeds. Not consumed by + * ProjectItemDelegate or ContentProviderPage today - both simply show + * an empty list - but a QML caller with no progress widget of its own + * to hang activeSearchJob() off needs something to show for a search + * that came back with nothing. */ + QString lastError() const + { + return m_lastError; + } + signals: /* A search started, finished or failed - drives the inline progress * bar on the page. */ @@ -321,6 +384,7 @@ class ContentProviderModel : public QAbstractListModel NetJob::Ptr m_searchJob; QByteArray m_searchResponse; + QString m_lastError; /* Bumped on every reset. Replies tagged with an older generation * belong to a search whose results are already gone. */ diff --git a/launcher/modplatform/DependencyResolver.cpp b/launcher/modplatform/DependencyResolver.cpp index 6bca1332..a7551ac2 100644 --- a/launcher/modplatform/DependencyResolver.cpp +++ b/launcher/modplatform/DependencyResolver.cpp @@ -18,8 +18,8 @@ */ #include "DependencyResolver.h" -#include "Application.h" #include "Json.h" +#include "core/LauncherContext.h" #include "minecraft/mod/ModMetadataIndex.h" #include "modplatform/ContentType.h" #include "modplatform/VersionPicker.h" @@ -222,7 +222,7 @@ void DependencyResolver::request(const QString& name, const QUrl& url, * of them runs, and if the job dies without either firing the buffer * goes with it instead of leaking. */ auto response = std::make_shared(); - auto* job = new NetJob(name, APPLICATION->network()); + auto* job = new NetJob(name, LAUNCHER->network()); job->addNetAction(Net::Download::makeByteArray(url, response.get())); m_pendingRequests++; diff --git a/launcher/modplatform/ModUpdateCheckTask.cpp b/launcher/modplatform/ModUpdateCheckTask.cpp index d23660a8..cea793da 100644 --- a/launcher/modplatform/ModUpdateCheckTask.cpp +++ b/launcher/modplatform/ModUpdateCheckTask.cpp @@ -25,8 +25,8 @@ #include #include -#include "Application.h" #include "Json.h" +#include "core/LauncherContext.h" #include "minecraft/mod/ModMetadataIndex.h" #include "modplatform/ContentType.h" #include "modplatform/VersionPicker.h" @@ -255,7 +255,7 @@ void ModUpdateCheckTask::executeTask() auto response = std::make_shared(); NetJob* job = new NetJob( QString("UpdateCheck(%1:%2)").arg(e.platform, e.projectId), - APPLICATION->network()); + LAUNCHER->network()); job->addNetAction( Net::Download::makeByteArray(QUrl(url), response.get())); diff --git a/launcher/modplatform/VersionPicker.cpp b/launcher/modplatform/VersionPicker.cpp index 4bd42102..4a8da23b 100644 --- a/launcher/modplatform/VersionPicker.cpp +++ b/launcher/modplatform/VersionPicker.cpp @@ -64,6 +64,11 @@ namespace } // namespace +bool ModPlatform::isKnownLoaderName(const QString& tag) +{ + return knownLoaderNames().contains(tag.toLower()); +} + QJsonObject ModPlatform::newestCurseForgeFile(const QJsonArray& files, const QString& loader) { diff --git a/launcher/modplatform/VersionPicker.h b/launcher/modplatform/VersionPicker.h index 1a1793a5..4fa781fb 100644 --- a/launcher/modplatform/VersionPicker.h +++ b/launcher/modplatform/VersionPicker.h @@ -55,4 +55,12 @@ namespace ModPlatform QJsonObject newestModrinthVersion(const QJsonArray& versions, const QString& loader); + /* Whether `tag` (already lower-cased or not - compared case + * insensitively) names a mod loader CurseForge might mix into a + * file's "gameVersions" array alongside actual Minecraft versions. + * Exported so that anything else parsing that same mixed array - + * FlameContentModel::parseVersionsResponse(), namely - does not carry + * a second copy of the list to keep in sync with this one. */ + bool isKnownLoaderName(const QString& tag); + } // namespace ModPlatform diff --git a/launcher/modplatform/atlauncher/ATLPackInstallTask.cpp b/launcher/modplatform/atlauncher/ATLPackInstallTask.cpp index 8e987870..c8fef695 100644 --- a/launcher/modplatform/atlauncher/ATLPackInstallTask.cpp +++ b/launcher/modplatform/atlauncher/ATLPackInstallTask.cpp @@ -39,7 +39,7 @@ #include "meta/VersionList.h" #include "BuildConfig.h" -#include "Application.h" +#include "core/LauncherContext.h" namespace ATLauncher { @@ -71,7 +71,7 @@ namespace ATLauncher qDebug() << "PackInstallTask::executeTask: " << QThread::currentThreadId(); auto* netJob = - new NetJob("ATLauncher::VersionFetch", APPLICATION->network()); + new NetJob("ATLauncher::VersionFetch", LAUNCHER->network()); auto searchUrl = QString(BuildConfig.ATL_DOWNLOAD_SERVER_URL + "packs/%1/versions/%2/Configs.json") .arg(m_pack) @@ -114,7 +114,7 @@ namespace ATLauncher } m_version = version; - auto vlist = APPLICATION->metadataIndex()->get("net.minecraft"); + auto vlist = LAUNCHER->metadataIndex()->get("net.minecraft"); if (!vlist) { emitFailed(tr("Failed to get local metadata index for %1") .arg("net.minecraft")); @@ -198,7 +198,7 @@ namespace ATLauncher { if (m_version.loader.recommended || m_version.loader.latest || m_version.loader.choose) { - auto vlist = APPLICATION->metadataIndex()->get(uid); + auto vlist = LAUNCHER->metadataIndex()->get(uid); if (!vlist) { emitFailed( tr("Failed to get local metadata index for %1").arg(uid)); @@ -460,7 +460,7 @@ namespace ATLauncher qDebug() << "PackInstallTask::installConfigs: " << QThread::currentThreadId(); setStatus(tr("Downloading configs...")); - jobPtr = new NetJob(tr("Config download"), APPLICATION->network()); + jobPtr = new NetJob(tr("Config download"), LAUNCHER->network()); auto path = QString("Configs/%1/%2.zip").arg(m_pack).arg(m_version_name); @@ -469,7 +469,7 @@ namespace ATLauncher .arg(m_pack) .arg(m_version_name); auto entry = - APPLICATION->metacache()->resolveEntry("ATLauncherPacks", path); + LAUNCHER->metacache()->resolveEntry("ATLauncherPacks", path); entry->setStale(true); auto dl = Net::Download::makeCached(url, entry); @@ -546,7 +546,7 @@ namespace ATLauncher setStatus(tr("Downloading mods...")); jarmods.clear(); - jobPtr = new NetJob(tr("Mod download"), APPLICATION->network()); + jobPtr = new NetJob(tr("Mod download"), LAUNCHER->network()); for (const auto& mod : m_version.mods) { // skip non-client mods if (!mod.client) @@ -581,7 +581,7 @@ namespace ATLauncher if (mod.type == ModType::Extract || mod.type == ModType::TexturePackExtract || mod.type == ModType::ResourcePackExtract) { - auto entry = APPLICATION->metacache()->resolveEntry( + auto entry = LAUNCHER->metacache()->resolveEntry( "ATLauncherPacks", cacheName); entry->setStale(true); modsToExtract.insert(entry->getFullPath(), mod); @@ -594,7 +594,7 @@ namespace ATLauncher } jobPtr->addNetAction(dl); } else if (mod.type == ModType::Decomp) { - auto entry = APPLICATION->metacache()->resolveEntry( + auto entry = LAUNCHER->metacache()->resolveEntry( "ATLauncherPacks", cacheName); entry->setStale(true); modsToDecomp.insert(entry->getFullPath(), mod); @@ -611,7 +611,7 @@ namespace ATLauncher if (relpath == Q_NULLPTR) continue; - auto entry = APPLICATION->metacache()->resolveEntry( + auto entry = LAUNCHER->metacache()->resolveEntry( "ATLauncherPacks", cacheName); entry->setStale(true); @@ -630,7 +630,7 @@ namespace ATLauncher if (mod.type == ModType::Forge) { auto vlist = - APPLICATION->metadataIndex()->get("net.minecraftforge"); + LAUNCHER->metadataIndex()->get("net.minecraftforge"); if (vlist) { auto ver = vlist->getVersion(mod.version); if (ver) { diff --git a/launcher/modplatform/flame/FlameContentModel.cpp b/launcher/modplatform/flame/FlameContentModel.cpp index 1a5f06c5..55c4741d 100644 --- a/launcher/modplatform/flame/FlameContentModel.cpp +++ b/launcher/modplatform/flame/FlameContentModel.cpp @@ -27,6 +27,7 @@ #include "Json.h" #include "modplatform/ModDownloadTypes.h" +#include "modplatform/VersionPicker.h" #include "modplatform/flame/FlameApi.h" namespace @@ -226,6 +227,19 @@ QList FlameContentModel::parseVersionsResponse( version.fileSize = Json::ensureInteger(fileObj, "fileLength", 0); version.versionType = releaseTypeName(Json::ensureInteger(fileObj, "releaseType", 0)); + version.datePublished = Json::ensureString(fileObj, "fileDate", ""); + + /* CurseForge mixes Minecraft versions and loader names into one + * "gameVersions" array - see ModPlatform::newestCurseForgeFile(), + * which has to untangle the same thing for the same reason. */ + for (const auto& tagRaw : Json::ensureArray(fileObj, "gameVersions")) { + const QString tag = tagRaw.toString(); + if (ModPlatform::isKnownLoaderName(tag)) { + version.loaders.append(tag); + } else { + version.gameVersions.append(tag); + } + } /* Authors can forbid third-party downloads, in which case the * API hands out no URL at all. The site still serves the file, diff --git a/launcher/modplatform/legacy_ftb/PackFetchTask.cpp b/launcher/modplatform/legacy_ftb/PackFetchTask.cpp index d3335e6a..8ce1baba 100644 --- a/launcher/modplatform/legacy_ftb/PackFetchTask.cpp +++ b/launcher/modplatform/legacy_ftb/PackFetchTask.cpp @@ -22,7 +22,6 @@ #include #include "BuildConfig.h" -#include "Application.h" namespace LegacyFTB { diff --git a/launcher/modplatform/legacy_ftb/PackInstallTask.cpp b/launcher/modplatform/legacy_ftb/PackInstallTask.cpp index efbe883c..6d248f94 100644 --- a/launcher/modplatform/legacy_ftb/PackInstallTask.cpp +++ b/launcher/modplatform/legacy_ftb/PackInstallTask.cpp @@ -30,7 +30,7 @@ #include "minecraft/GradleSpecifier.h" #include "BuildConfig.h" -#include "Application.h" +#include "core/LauncherContext.h" namespace LegacyFTB { @@ -57,7 +57,7 @@ namespace LegacyFTB QString("%1/%2/%3") .arg(m_pack.dir, m_version.replace(".", "_"), m_pack.file); auto entry = - APPLICATION->metacache()->resolveEntry("FTBPacks", packoffset); + LAUNCHER->metacache()->resolveEntry("FTBPacks", packoffset); netJobContainer = new NetJob("Download FTB Pack", m_network); entry->setStale(true); diff --git a/launcher/modplatform/modpacksch/FTBPackInstallTask.cpp b/launcher/modplatform/modpacksch/FTBPackInstallTask.cpp index f5c85e1a..63092191 100644 --- a/launcher/modplatform/modpacksch/FTBPackInstallTask.cpp +++ b/launcher/modplatform/modpacksch/FTBPackInstallTask.cpp @@ -29,7 +29,7 @@ #include "settings/INISettingsObject.h" #include "BuildConfig.h" -#include "Application.h" +#include "core/LauncherContext.h" #include @@ -77,7 +77,7 @@ namespace ModpacksCH } auto* netJob = - new NetJob("ModpacksCH::VersionFetch", APPLICATION->network()); + new NetJob("ModpacksCH::VersionFetch", LAUNCHER->network()); auto searchUrl = QString(BuildConfig.MODPACKSCH_API_BASE_URL + "public/modpack/%1/%2") .arg(m_pack.id) @@ -130,7 +130,7 @@ namespace ModpacksCH { setStatus(tr("Downloading mods...")); - jobPtr = new NetJob(tr("Mod download"), APPLICATION->network()); + jobPtr = new NetJob(tr("Mod download"), LAUNCHER->network()); for (auto file : m_version.files) { if (file.serverOnly) continue; @@ -144,7 +144,7 @@ namespace ModpacksCH auto cacheName = fileName.completeBaseName() + "-" + file.sha1 + "." + fileName.suffix(); - auto entry = APPLICATION->metacache()->resolveEntry( + auto entry = LAUNCHER->metacache()->resolveEntry( "ModpacksCHPacks", cacheName); entry->setStale(true); diff --git a/launcher/modplatform/modrinth/ModrinthApi.cpp b/launcher/modplatform/modrinth/ModrinthApi.cpp index 3145b506..90006713 100644 --- a/launcher/modplatform/modrinth/ModrinthApi.cpp +++ b/launcher/modplatform/modrinth/ModrinthApi.cpp @@ -58,18 +58,6 @@ namespace QStringLiteral("]"); } - /* The common case: every term stands alone and all of them must - * match. */ - QString facetList(const QStringList& terms) - { - QList groups; - groups.reserve(terms.size()); - for (const QString& term : terms) { - groups.append(QStringList{term}); - } - return facetGroups(groups); - } - /* Turn the environment choice into facet groups. * * Modrinth records how a project relates to each side separately, so @@ -364,8 +352,30 @@ QUrl ModrinthApi::projectVersionsUrlForLoaders(const QString& projectId, } QUrl ModrinthApi::modpackSearchUrl(const QString& term, int sortIndex, - int offset) + int offset, const QString& gameVersion, + const QStringList& loaders) { + QList facets; + facets.append(QStringList{QStringLiteral("project_type:modpack")}); + + if (!gameVersion.isEmpty()) { + facets.append( + QStringList{QStringLiteral("versions:") + gameVersion}); + } + + if (!loaders.isEmpty()) { + /* A modpack ships its own loader, but browsing "any Fabric + * pack" is still a reasonable filter to offer - one group, so + * several ticked loaders mean "any of these", same as the mod + * search above. */ + QStringList loaderFacets; + loaderFacets.reserve(loaders.size()); + for (const QString& loader : loaders) { + loaderFacets.append(QStringLiteral("categories:") + loader); + } + facets.append(loaderFacets); + } + return QUrl(QString("%1/search?" "query=%2&" "facets=%3&" @@ -373,7 +383,7 @@ QUrl ModrinthApi::modpackSearchUrl(const QString& term, int sortIndex, "offset=%5&" "limit=%6") .arg(apiBase(), ModPlatform::encodeSearchTerm(term), - facetList({QStringLiteral("project_type:modpack")}), + facetGroups(facets), sortValueAt(get().sortingMethods(), sortIndex), QString::number(offset), QString::number(get().searchPageSize()))); diff --git a/launcher/modplatform/modrinth/ModrinthApi.h b/launcher/modplatform/modrinth/ModrinthApi.h index 6d239d42..9e912bde 100644 --- a/launcher/modplatform/modrinth/ModrinthApi.h +++ b/launcher/modplatform/modrinth/ModrinthApi.h @@ -111,9 +111,16 @@ class ModrinthApi final : public ModPlatform::ContentApi /* Modpack browsing. Separate from searchUrl() because modpacks are * not a ContentType - they create instances rather than being - * installed into one - and take no version or loader facet. */ - static QUrl modpackSearchUrl(const QString& term, int sortIndex, - int offset); + * installed into one, so this takes its own, narrower filter set + * rather than the mod-oriented SearchFilters. + * + * @p gameVersion and @p loaders are optional (empty means "any") and + * additive to the existing three-argument form: every call site that + * predates them keeps compiling and keeps its old behaviour. */ + static QUrl + modpackSearchUrl(const QString& term, int sortIndex, int offset, + const QString& gameVersion = QString(), + const QStringList& loaders = QStringList()); /* Narrow mod-name lookup, used when a dependency could not be * resolved on its own platform and we go looking for it here. diff --git a/launcher/modplatform/modrinth/ModrinthContentModel.cpp b/launcher/modplatform/modrinth/ModrinthContentModel.cpp index f2c2a3e5..b65d98e5 100644 --- a/launcher/modplatform/modrinth/ModrinthContentModel.cpp +++ b/launcher/modplatform/modrinth/ModrinthContentModel.cpp @@ -193,12 +193,23 @@ QList ModrinthContentModel::parseVersionsResponse( const QString name = Json::ensureString(versionObj, "name", ""); const QString number = Json::ensureString(versionObj, "version_number", ""); + version.versionNumber = number; version.name = name.isEmpty() ? number : (number.isEmpty() ? name : QString("%1 (%2)").arg(name, number)); + for (const auto& tag : + Json::ensureArray(versionObj, "game_versions")) { + version.gameVersions.append(tag.toString()); + } + for (const auto& tag : Json::ensureArray(versionObj, "loaders")) { + version.loaders.append(tag.toString()); + } + version.datePublished = + Json::ensureString(versionObj, "date_published", ""); + const auto files = Json::ensureArray(versionObj, "files"); for (const auto& fileRaw : files) { const auto fileObj = fileRaw.toObject(); diff --git a/launcher/modplatform/modrinth/ModrinthModpackModel.cpp b/launcher/modplatform/modrinth/ModrinthModpackModel.cpp new file mode 100644 index 00000000..5deb7550 --- /dev/null +++ b/launcher/modplatform/modrinth/ModrinthModpackModel.cpp @@ -0,0 +1,752 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "ModrinthModpackModel.h" + +#include + +#include +#include +#include +#include +#include + +#include "InstanceImportTask.h" +#include "InstanceList.h" +#include "Json.h" +#include "core/LauncherContext.h" +#include "icons/IconList.h" +#include "modplatform/ContentApi.h" +#include "modplatform/modrinth/ModrinthApi.h" +#include "net/Download.h" +#include "net/HttpMetaCache.h" +#include "tasks/TaskWatcher.h" + +/* ---------------------------------------------------------------- */ +/* ModrinthModpackDetail */ +/* ---------------------------------------------------------------- */ + +ModrinthModpackDetail::ModrinthModpackDetail(QObject* parent) : QObject(parent) +{ +} + +void ModrinthModpackDetail::reset(const QString& projectId) +{ + if (m_projectId != projectId) { + m_projectId = projectId; + emit projectIdChanged(); + } + setTitle(QString()); + setBody(QString()); + setVersions(QVariantList()); + setGallery(QVariantList()); + setError(QString()); + setLoading(true); +} + +void ModrinthModpackDetail::setTitle(const QString& title) +{ + if (m_title == title) { + return; + } + m_title = title; + emit titleChanged(); +} + +void ModrinthModpackDetail::setBody(const QString& body) +{ + if (m_body == body) { + return; + } + m_body = body; + emit bodyChanged(); +} + +void ModrinthModpackDetail::setVersions(const QVariantList& versions) +{ + m_versions = versions; + emit versionsChanged(); +} + +void ModrinthModpackDetail::setGallery(const QVariantList& gallery) +{ + m_gallery = gallery; + emit galleryChanged(); +} + +void ModrinthModpackDetail::setLoading(bool loading) +{ + if (m_loading == loading) { + return; + } + m_loading = loading; + emit loadingChanged(); +} + +void ModrinthModpackDetail::setError(const QString& error) +{ + if (m_error == error) { + return; + } + m_error = error; + emit errorChanged(); +} + +/* ---------------------------------------------------------------- */ +/* ModrinthModpackModel */ +/* ---------------------------------------------------------------- */ + +ModrinthModpackModel::ModrinthModpackModel(QObject* parent) + : QAbstractListModel(parent), m_detail(new ModrinthModpackDetail(this)) +{ + for (const auto& sorting : ModrinthApi::get().sortingMethods()) { + QVariantMap entry; + entry[QStringLiteral("id")] = sorting.apiValue; + entry[QStringLiteral("label")] = sorting.readableName; + m_sortOptions.append(entry); + } + if (!m_sortOptions.isEmpty()) { + m_sort = m_sortOptions.first().toMap().value(QStringLiteral("id")) + .toString(); + } +} + +ModrinthModpackModel::~ModrinthModpackModel() +{ + if (m_searchJob) { + m_searchJob->abort(); + } + if (m_bodyJob) { + m_bodyJob->abort(); + } + if (m_versionsJob) { + m_versionsJob->abort(); + } +} + +int ModrinthModpackModel::rowCount(const QModelIndex& parent) const +{ + return parent.isValid() ? 0 : m_packs.size(); +} + +QVariant ModrinthModpackModel::data(const QModelIndex& index, int role) const +{ + if (!index.isValid() || index.row() < 0 || + index.row() >= m_packs.size()) { + return QVariant(); + } + const Modrinth::IndexedPack& pack = m_packs.at(index.row()); + + switch (role) { + case ProjectIdRole: + return pack.projectId; + case SlugRole: + return pack.slug; + case TitleRole: + return pack.name; + case DescriptionRole: + return pack.description; + case AuthorRole: + return pack.author; + case LogoUrlRole: + return pack.iconUrl; + case DownloadsRole: + return pack.downloads; + case FollowsRole: + return pack.follows; + case UpdatedRole: + return pack.dateModified; + case LatestVersionRole: + return pack.latestVersion; + case GameVersionsRole: + return QVariant::fromValue(pack.gameVersions); + case CategoriesRole: + /* display_categories when the hit had it - the curated + * set Modrinth means for a card - falling back to the + * full technical list otherwise. */ + return QVariant::fromValue(pack.displayCategories.isEmpty() + ? pack.categories + : pack.displayCategories); + case GalleryUrlRole: + if (!pack.featuredGalleryUrl.isEmpty()) { + return pack.featuredGalleryUrl; + } + return pack.galleryUrls.isEmpty() ? QString() + : pack.galleryUrls.first(); + case AccentColorRole: + if (pack.color < 0) { + return QVariant(); + } + return QVariant::fromValue( + QColor::fromRgb((pack.color >> 16) & 0xFF, + (pack.color >> 8) & 0xFF, pack.color & 0xFF)); + default: + return QVariant(); + } +} + +QHash ModrinthModpackModel::roleNames() const +{ + return { + {ProjectIdRole, "projectId"}, + {SlugRole, "slug"}, + {TitleRole, "title"}, + {DescriptionRole, "description"}, + {AuthorRole, "author"}, + {LogoUrlRole, "logoUrl"}, + {DownloadsRole, "downloads"}, + {FollowsRole, "follows"}, + {UpdatedRole, "updated"}, + {LatestVersionRole, "latestVersion"}, + {GameVersionsRole, "gameVersions"}, + {CategoriesRole, "categories"}, + {GalleryUrlRole, "galleryUrl"}, + {AccentColorRole, "accentColor"}, + }; +} + +void ModrinthModpackModel::setQuery(const QString& query) +{ + if (m_query == query) { + return; + } + m_query = query; + emit queryChanged(); +} + +void ModrinthModpackModel::setSort(const QString& sort) +{ + if (m_sort == sort) { + return; + } + m_sort = sort; + emit sortChanged(); +} + +void ModrinthModpackModel::setGameVersion(const QString& gameVersion) +{ + if (m_gameVersion == gameVersion) { + return; + } + m_gameVersion = gameVersion; + emit gameVersionChanged(); +} + +void ModrinthModpackModel::setLoader(const QString& loader) +{ + if (m_loader == loader) { + return; + } + m_loader = loader; + emit loaderChanged(); +} + +void ModrinthModpackModel::setSearching(bool searching) +{ + if (m_searching == searching) { + return; + } + m_searching = searching; + emit searchingChanged(); +} + +void ModrinthModpackModel::setCanFetchMore(bool canFetchMore) +{ + if (m_canFetchMore == canFetchMore) { + return; + } + m_canFetchMore = canFetchMore; + emit canFetchMoreChanged(); +} + +void ModrinthModpackModel::setError(const QString& error) +{ + if (m_error == error) { + return; + } + m_error = error; + emit errorChanged(); +} + +int ModrinthModpackModel::sortIndexOf(const QString& id) const +{ + for (int i = 0; i < m_sortOptions.size(); ++i) { + if (m_sortOptions.at(i).toMap().value(QStringLiteral("id")).toString() == + id) { + return i; + } + } + return 0; +} + +void ModrinthModpackModel::search() +{ + if (m_searchJob) { + /* A reply for the previous query may still be on its way. + * Marked before abort() is called: aborting can complete + * synchronously, and the failure handler needs to already see + * the new intent when that happens - same pattern as + * ContentProviderModel::search() / Modrinth::ListModel:: + * searchWithTerm(). */ + m_restartPending = true; + m_searchJob->abort(); + return; + } + restartSearch(); +} + +void ModrinthModpackModel::restartSearch() +{ + beginResetModel(); + m_packs.clear(); + endResetModel(); + emit countChanged(); + + m_nextOffset = 0; + setCanFetchMore(false); + setError(QString()); + performSearch(); +} + +void ModrinthModpackModel::fetchMore() +{ + if (m_searching || !m_canFetchMore) { + return; + } + performSearch(); +} + +void ModrinthModpackModel::performSearch() +{ + m_searchResponse.clear(); + + auto* job = + new NetJob(QStringLiteral("Modrinth::ModpackSearch"), LAUNCHER->network()); + job->addNetAction(Net::Download::makeByteArray( + ModrinthApi::modpackSearchUrl(m_query, sortIndexOf(m_sort), + m_nextOffset, m_gameVersion, + ModPlatform::singleLoaderList(m_loader)), + &m_searchResponse)); + + m_searchJob = job; + connect(job, &NetJob::succeeded, this, + &ModrinthModpackModel::onSearchSucceeded); + connect(job, &NetJob::failed, this, + &ModrinthModpackModel::onSearchFailed); + + job->start(); + setSearching(true); +} + +void ModrinthModpackModel::onSearchSucceeded() +{ + m_searchJob.reset(); + + if (m_restartPending) { + m_restartPending = false; + restartSearch(); + return; + } + + int totalHits = -1; + const QList newPacks = + parseSearchResults(m_searchResponse, totalHits); + + if (!newPacks.isEmpty()) { + const int first = m_packs.size(); + beginInsertRows(QModelIndex(), first, first + newPacks.size() - 1); + m_packs.append(newPacks); + endInsertRows(); + emit countChanged(); + } + + const int pageSize = ModrinthApi::get().searchPageSize(); + const bool lastPage = + newPacks.size() < pageSize || + (totalHits >= 0 && (m_nextOffset + newPacks.size()) >= totalHits); + if (lastPage) { + setCanFetchMore(false); + } else { + m_nextOffset += pageSize; + setCanFetchMore(true); + } + setSearching(false); +} + +void ModrinthModpackModel::onSearchFailed(QString reason) +{ + m_searchJob.reset(); + + if (m_restartPending) { + m_restartPending = false; + restartSearch(); + return; + } + + setCanFetchMore(false); + setSearching(false); + setError(reason); +} + +QList +ModrinthModpackModel::parseSearchResults(const QByteArray& bytes, + int& totalHits) +{ + QList results; + totalHits = -1; + + QJsonParseError parseError; + const QJsonDocument doc = QJsonDocument::fromJson(bytes, &parseError); + if (parseError.error != QJsonParseError::NoError) { + qWarning() << "Error while parsing JSON response from Modrinth at" + << parseError.offset + << "reason:" << parseError.errorString(); + return results; + } + + const QJsonObject obj = doc.object(); + const QJsonArray hits = Json::ensureArray(obj, "hits"); + for (const QJsonValue& hitRaw : hits) { + QJsonObject hitObj = hitRaw.toObject(); + Modrinth::IndexedPack pack; + try { + Modrinth::loadIndexedPack(pack, hitObj); + results.append(pack); + } catch (const JSONValidationError& e) { + qWarning() << "Error while loading modpack from Modrinth:" + << e.cause(); + } + } + + totalHits = Json::ensureInteger(obj, "total_hits", 0); + return results; +} + +void ModrinthModpackModel::loadDetail(const QString& projectId) +{ + if (projectId.isEmpty()) { + return; + } + + if (m_bodyJob) { + m_bodyJob->abort(); + m_bodyJob.reset(); + } + if (m_versionsJob) { + m_versionsJob->abort(); + m_versionsJob.reset(); + } + + ++m_detailGeneration; + const quint64 generation = m_detailGeneration; + m_bodyDone = false; + m_versionsDone = false; + + m_detail->reset(projectId); + for (const auto& pack : m_packs) { + if (pack.projectId == projectId) { + m_detail->setTitle(pack.name); + break; + } + } + + fetchDetailBody(projectId, generation); + fetchDetailVersions(projectId, generation); +} + +void ModrinthModpackModel::markDetailPartDone(quint64 generation, bool isBody) +{ + if (generation != m_detailGeneration) { + /* A newer loadDetail() call has already moved on. */ + return; + } + if (isBody) { + m_bodyDone = true; + } else { + m_versionsDone = true; + } + if (m_bodyDone && m_versionsDone) { + m_detail->setLoading(false); + } +} + +void ModrinthModpackModel::fetchDetailBody(const QString& projectId, + quint64 generation) +{ + auto response = std::make_shared(); + auto* job = new NetJob(QStringLiteral("Modrinth::Project(%1)").arg(projectId), + LAUNCHER->network()); + job->addNetAction(Net::Download::makeByteArray( + ModrinthApi::get().projectBodyUrl(projectId), response.get())); + + m_bodyJob = job; + connect(job, &NetJob::succeeded, this, + [this, job, response, generation] { + job->deleteLater(); + if (generation == m_detailGeneration) { + m_bodyJob.reset(); + QJsonParseError parseError; + const QJsonDocument doc = + QJsonDocument::fromJson(*response, &parseError); + if (parseError.error == QJsonParseError::NoError) { + const QJsonObject obj = doc.object(); + m_detail->setBody( + Json::ensureString(obj, "body", QString())); + const QString title = + Json::ensureString(obj, "title", QString()); + if (!title.isEmpty()) { + m_detail->setTitle(title); + } + + /* The full project object carries a richer + * "gallery" than a search hit does: objects + * with their own title/featured flag rather + * than bare URLs (see loadIndexedPack() for + * the search-hit shape). Only entries with a + * URL are kept; featured images are sorted + * first so the detail header always prefers + * one, same as Modrinth's own project page. */ + QVariantList gallery; + for (const auto& imageRaw : + Json::ensureArray(obj, "gallery")) { + const QJsonObject imageObj = imageRaw.toObject(); + const QString url = + Json::ensureString(imageObj, "url", QString()); + if (url.isEmpty()) { + continue; + } + QVariantMap entry; + entry[QStringLiteral("url")] = url; + entry[QStringLiteral("featured")] = + Json::ensureBoolean(imageObj, "featured", false); + entry[QStringLiteral("title")] = + Json::ensureString(imageObj, "title", QString()); + gallery.append(entry); + } + std::stable_sort( + gallery.begin(), gallery.end(), + [](const QVariant& a, const QVariant& b) { + return a.toMap().value(QStringLiteral("featured")).toBool() && + !b.toMap().value(QStringLiteral("featured")).toBool(); + }); + m_detail->setGallery(gallery); + } + } + markDetailPartDone(generation, true); + }); + connect(job, &NetJob::failed, this, + [this, job, generation](QString reason) { + job->deleteLater(); + if (generation == m_detailGeneration) { + m_bodyJob.reset(); + m_detail->setError(reason); + } + markDetailPartDone(generation, true); + }); + job->start(); +} + +void ModrinthModpackModel::fetchDetailVersions(const QString& projectId, + quint64 generation) +{ + auto response = std::make_shared(); + auto* job = new NetJob( + QStringLiteral("Modrinth::PackVersions(%1)").arg(projectId), + LAUNCHER->network()); + /* A modpack ships its own loader, so accept any of them here rather + * than filtering to whatever instance the user might currently have + * selected - same reasoning, and the same call, as + * ModrinthPage::onSelectionChanged(). */ + job->addNetAction(Net::Download::makeByteArray( + ModrinthApi::projectVersionsUrlForLoaders( + projectId, {QStringLiteral("forge"), QStringLiteral("fabric"), + QStringLiteral("quilt"), QStringLiteral("neoforge")}), + response.get())); + + m_versionsJob = job; + connect(job, &NetJob::succeeded, this, + [this, job, response, projectId, generation] { + job->deleteLater(); + if (generation == m_detailGeneration) { + m_versionsJob.reset(); + m_detail->setVersions( + parseVersionsJson(*response, projectId)); + } + markDetailPartDone(generation, false); + }); + connect(job, &NetJob::failed, this, + [this, job, generation](QString reason) { + job->deleteLater(); + if (generation == m_detailGeneration) { + m_versionsJob.reset(); + m_detail->setError(reason); + } + markDetailPartDone(generation, false); + }); + job->start(); +} + +QVariantList +ModrinthModpackModel::parseVersionsJson(const QByteArray& bytes, + const QString& projectId) +{ + QVariantList result; + + QJsonParseError parseError; + const QJsonDocument doc = QJsonDocument::fromJson(bytes, &parseError); + if (parseError.error != QJsonParseError::NoError) { + qWarning() << "Error while parsing JSON response from Modrinth at" + << parseError.offset + << "reason:" << parseError.errorString(); + return result; + } + + QJsonArray arr = doc.array(); + Modrinth::IndexedPack tempPack; + tempPack.projectId = projectId; + try { + Modrinth::loadIndexedPackVersions(tempPack, arr); + } catch (const JSONValidationError& e) { + qWarning() << "Error while reading Modrinth modpack versions:" + << e.cause(); + return result; + } + + for (const auto& version : tempPack.versions) { + QVariantMap entry; + entry[QStringLiteral("id")] = version.id; + entry[QStringLiteral("name")] = version.name; + entry[QStringLiteral("versionNumber")] = version.versionNumber; + entry[QStringLiteral("gameVersions")] = + QVariant::fromValue(version.gameVersions); + entry[QStringLiteral("loaders")] = + QVariant::fromValue(version.loaderList); + entry[QStringLiteral("datePublished")] = version.datePublished; + entry[QStringLiteral("downloadUrl")] = version.downloadUrl; + entry[QStringLiteral("featured")] = version.featured; + result.append(entry); + } + return result; +} + +QString ModrinthModpackModel::resolveIconKey(const QString& slug, + const QString& iconUrl) const +{ + if (!slug.isEmpty() && !iconUrl.isEmpty()) { + /* Same bucket the widget Modrinth page + * (ui/pages/modplatform/modrinth/ModrinthModel.cpp) has always + * cached these icons under, so a pack browsed there before + * already has a hit waiting here. resolveEntry() is a disk-only + * lookup - it never starts a download - and only comes back + * non-stale once it has verified the file is actually there. */ + MetaEntryPtr entry = LAUNCHER->metacache()->resolveEntry( + QStringLiteral("ModrinthPacks"), + QStringLiteral("logos/%1").arg(slug)); + if (entry && !entry->isStale()) { + const QString key = QStringLiteral("modrinth_") + slug; + LAUNCHER->icons()->installIcon(entry->getFullPath(), key); + return key; + } + } + return QStringLiteral("modrinth"); +} + +QObject* ModrinthModpackModel::install(const QString& projectId, + const QString& versionId, + const QString& instanceName, + const QString& group) +{ + QString packTitle; + QString packSlug; + QString iconUrl; + for (const auto& pack : m_packs) { + if (pack.projectId == projectId) { + packTitle = pack.name; + packSlug = pack.slug; + iconUrl = pack.iconUrl; + break; + } + } + + QString downloadUrl; + QString versionLabel; + if (m_detail->projectId() == projectId) { + if (packTitle.isEmpty()) { + packTitle = m_detail->title(); + } + for (const QVariant& versionVariant : m_detail->versions()) { + const QVariantMap versionMap = versionVariant.toMap(); + if (versionMap.value(QStringLiteral("id")).toString() == + versionId) { + downloadUrl = + versionMap.value(QStringLiteral("downloadUrl")).toString(); + versionLabel = + versionMap.value(QStringLiteral("versionNumber")) + .toString(); + break; + } + } + } + + if (downloadUrl.isEmpty()) { + qWarning() << "ModrinthModpackModel::install: no download URL for " + "version" + << versionId << "of project" << projectId + << "- was loadDetail() called and finished first?"; + return nullptr; + } + + /* Builds exactly what ModrinthPage::suggestCurrent() builds today + * (ui/pages/modplatform/modrinth/ModrinthPage.cpp), and what + * NewInstanceDialog::extractTask() + + * MainWindow::createInstanceFromDialog() then do to it - the four + * setters below and wrapInstanceTask() are that same funnel. The + * only real difference is where the name/group/icon/target + * directory come from: the dialog reads its own widgets, this reads + * the caller's arguments (and the primary instance folder, since a + * headless install has no folder picker). */ + auto* importTask = new InstanceImportTask(QUrl(downloadUrl)); + importTask->setTrustedSource(true); + + InstanceImportTask::PackSourceHint hint; + hint.provider = QStringLiteral("modrinth"); + hint.packId = projectId; + hint.packSlug = packSlug; + hint.versionId = versionId; + hint.versionLabel = versionLabel; + hint.iconUrl = iconUrl; + if (!packSlug.isEmpty()) { + hint.sourceUrl = + QStringLiteral("https://modrinth.com/modpack/%1").arg(packSlug); + } + importTask->setPackSourceHint(hint); + + const QString effectiveName = + instanceName.isEmpty() ? packTitle : instanceName; + importTask->setName(effectiveName); + importTask->setGroup(group); + importTask->setIcon(resolveIconKey(packSlug, iconUrl)); + importTask->setTargetDir(LAUNCHER->instances()->primaryDir()); + + Task* wrapped = LAUNCHER->instances()->wrapInstanceTask(importTask); + auto* watcher = new TaskWatcher(Task::Ptr(wrapped), this); + watcher->setTitle(effectiveName); + wrapped->start(); + return watcher; +} diff --git a/launcher/modplatform/modrinth/ModrinthModpackModel.h b/launcher/modplatform/modrinth/ModrinthModpackModel.h new file mode 100644 index 00000000..ae827c82 --- /dev/null +++ b/launcher/modplatform/modrinth/ModrinthModpackModel.h @@ -0,0 +1,346 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include + +#include + +#include "modplatform/modrinth/ModrinthPackIndex.h" + +/* The version list and long description of one modpack, fetched lazily + * once the user picks a row - the QML-facing, widget-free counterpart of + * what ModrinthPage.cpp keeps in `current` plus what its version combo box + * shows. A plain QObject rather than a role on the list model itself, + * because a detail pane binds to several fields at once and QML has no + * convenient way to bind to "the currently selected row of some other + * model". + * + * Owned by, and never outlives, the ModrinthModpackModel that exposes it + * through its `detail` property. */ +class ModrinthModpackDetail : public QObject +{ + Q_OBJECT + Q_PROPERTY(QString projectId READ projectId NOTIFY projectIdChanged) + Q_PROPERTY(QString title READ title NOTIFY titleChanged) + /* The project's long description, as Modrinth serves it: Markdown, + * not HTML - unlike ContentProviderModel::parseBodyResponse(), which + * both providers it wraps do convert. Left as-is here because + * nothing in the core can render Markdown; that is squarely a QML + * concern (a Markdown-aware Text item, or a conversion done in QML/ + * JS) and does not belong in MeshMC_core. */ + Q_PROPERTY(QString body READ body NOTIFY bodyChanged) + /* QVariantList of maps: id, name, versionNumber, gameVersions + * (list), loaders (list), datePublished, downloadUrl, featured. */ + Q_PROPERTY(QVariantList versions READ versions NOTIFY versionsChanged) + /* QVariantList of maps: url, featured, title - the project's own + * gallery, straight from the same project fetch body() comes from + * (see fetchDetailBody()). Empty when the project has no gallery. */ + Q_PROPERTY(QVariantList gallery READ gallery NOTIFY galleryChanged) + Q_PROPERTY(bool loading READ loading NOTIFY loadingChanged) + Q_PROPERTY(QString error READ error NOTIFY errorChanged) + + public: + explicit ModrinthModpackDetail(QObject* parent = nullptr); + + QString projectId() const + { + return m_projectId; + } + QString title() const + { + return m_title; + } + QString body() const + { + return m_body; + } + QVariantList versions() const + { + return m_versions; + } + QVariantList gallery() const + { + return m_gallery; + } + bool loading() const + { + return m_loading; + } + QString error() const + { + return m_error; + } + + /* Clears body/versions/error, sets loading, and switches to + * `projectId` - called by ModrinthModpackModel::loadDetail() before + * it starts the two fetches. */ + void reset(const QString& projectId); + void setTitle(const QString& title); + void setBody(const QString& body); + void setVersions(const QVariantList& versions); + void setGallery(const QVariantList& gallery); + void setLoading(bool loading); + void setError(const QString& error); + + signals: + void projectIdChanged(); + void titleChanged(); + void bodyChanged(); + void versionsChanged(); + void galleryChanged(); + void loadingChanged(); + void errorChanged(); + + private: + QString m_projectId; + QString m_title; + QString m_body; + QVariantList m_versions; + QVariantList m_gallery; + bool m_loading = false; + QString m_error; +}; + +/* Modrinth *modpack* search results for QML - the widget-free replacement + * for ui/pages/modplatform/modrinth/ModrinthModel.h (Modrinth::ListModel), + * which backs ModrinthPage today. + * + * Deliberately its own class rather than a widened ContentProviderModel: + * modpacks are not a ModPlatform::ContentType (see ContentApi.h - "modpacks + * create instances rather than being installed into one"), so the two + * families of search share a shape but not a base class. See + * ModrinthPackIndex.h for the parsed fields this exposes. + * + * Icons are handled the simplest possible way for v1: `logoUrl` is + * Modrinth's own CDN URL, and QML's `Image { source: logoUrl }` fetches + * and caches it directly. Nothing here downloads or caches a QIcon the way + * ContentProviderModel/Modrinth::ListModel do for their QWidget delegates - + * that machinery has no QML consumer. install() below still has to name a + * disk-resident *icon key* for the new instance, which it resolves from + * the launcher's existing on-disk icon cache without downloading anything; + * see resolveIconKey(). + */ +class ModrinthModpackModel : public QAbstractListModel +{ + Q_OBJECT + Q_PROPERTY(QString query READ query WRITE setQuery NOTIFY queryChanged) + Q_PROPERTY(QString sort READ sort WRITE setSort NOTIFY sortChanged) + Q_PROPERTY(QVariantList sortOptions READ sortOptions CONSTANT) + Q_PROPERTY(QString gameVersion READ gameVersion WRITE setGameVersion + NOTIFY gameVersionChanged) + /* "" = any loader; otherwise one of "fabric"/"forge"/"neoforge"/ + * "quilt". */ + Q_PROPERTY( + QString loader READ loader WRITE setLoader NOTIFY loaderChanged) + Q_PROPERTY(bool searching READ searching NOTIFY searchingChanged) + Q_PROPERTY( + bool canFetchMore READ canFetchMore NOTIFY canFetchMoreChanged) + Q_PROPERTY(int count READ count NOTIFY countChanged) + Q_PROPERTY(QString error READ error NOTIFY errorChanged) + Q_PROPERTY(QObject* detail READ detail CONSTANT) + + public: + enum Roles { + ProjectIdRole = Qt::UserRole + 1, + SlugRole, + TitleRole, + DescriptionRole, + AuthorRole, + LogoUrlRole, + DownloadsRole, + FollowsRole, + UpdatedRole, + LatestVersionRole, + GameVersionsRole, + CategoriesRole, + GalleryUrlRole, + AccentColorRole, + }; + Q_ENUM(Roles) + + explicit ModrinthModpackModel(QObject* parent = nullptr); + ~ModrinthModpackModel() override; + + int rowCount(const QModelIndex& parent = QModelIndex()) const override; + QVariant data(const QModelIndex& index, + int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + + /* `sort`, `canFetchMore` and `fetchMore` below are QML-facing + * convenience overloads (zero args, or an id string rather than a + * column) that happen to share a name with a QAbstractItemModel + * virtual of a different arity. These bring the base class's own + * overloads back into scope alongside them, which is what silences + * -Woverloaded-virtual - nothing here actually needs column + * sorting or index-based fetching, but a caller with a plain + * QAbstractItemModel* still should be able to reach them. */ + using QAbstractItemModel::canFetchMore; + using QAbstractItemModel::fetchMore; + using QAbstractItemModel::sort; + + QString query() const + { + return m_query; + } + void setQuery(const QString& query); + + QString sort() const + { + return m_sort; + } + void setSort(const QString& sort); + + QVariantList sortOptions() const + { + return m_sortOptions; + } + + QString gameVersion() const + { + return m_gameVersion; + } + void setGameVersion(const QString& gameVersion); + + QString loader() const + { + return m_loader; + } + void setLoader(const QString& loader); + + bool searching() const + { + return m_searching; + } + bool canFetchMore() const + { + return m_canFetchMore; + } + int count() const + { + return m_packs.size(); + } + QString error() const + { + return m_error; + } + QObject* detail() const + { + return m_detail; + } + + /* Starts a fresh search from query()/sort()/gameVersion()/loader(). + * Cancels a search already in flight rather than queuing behind it - + * matches Modrinth::ListModel::searchWithTerm() and + * ContentProviderModel::search(). No debounce: the caller (QML) is + * expected to only call this when the user is actually done typing/ + * picking, e.g. on Enter or a filter changing. */ + Q_INVOKABLE void search(); + /* Fetches the next page of the current search. A no-op while a + * search is already running or canFetchMore() is false. */ + Q_INVOKABLE void fetchMore(); + /* Fetches the version list and long description for one project, + * filling `detail`. Safe to call again for a different project while + * one is already loading - the stale reply is dropped. */ + Q_INVOKABLE void loadDetail(const QString& projectId); + /* Builds and starts the same InstanceImportTask that + * NewInstanceDialog::extractTask() + MainWindow::createInstanceFromDialog() + * build for a pack picked in the widget Modrinth browser, and returns + * a TaskWatcher for it (parented to this model). `versionId` must be + * one of the ids in `detail.versions` for `projectId` - call + * loadDetail() first. Returns nullptr (and logs a warning) if that + * version cannot be found, e.g. because loadDetail() was never + * called or has not finished yet. */ + Q_INVOKABLE QObject* install(const QString& projectId, + const QString& versionId, + const QString& instanceName, + const QString& group); + + /* JSON -> rows, pulled out as a static function so it can be unit + * tested with a canned response and no network. */ + static QList + parseSearchResults(const QByteArray& bytes, int& totalHits); + /* JSON -> the `versions` role of ModrinthModpackDetail, likewise + * network-free and unit testable on its own. Reuses + * Modrinth::loadIndexedPackVersions() for the actual parsing. */ + static QVariantList parseVersionsJson(const QByteArray& bytes, + const QString& projectId); + + signals: + void queryChanged(); + void sortChanged(); + void gameVersionChanged(); + void loaderChanged(); + void searchingChanged(); + void canFetchMoreChanged(); + void countChanged(); + void errorChanged(); + + private: + void setSearching(bool searching); + void setCanFetchMore(bool canFetchMore); + void setError(const QString& error); + + int sortIndexOf(const QString& id) const; + + void restartSearch(); + void performSearch(); + void onSearchSucceeded(); + void onSearchFailed(QString reason); + + void fetchDetailBody(const QString& projectId, quint64 generation); + void fetchDetailVersions(const QString& projectId, quint64 generation); + void markDetailPartDone(quint64 generation, bool isBody); + + /* The icon key to give the new instance: the slug's logo if it is + * already sitting in the launcher's on-disk cache (the same + * "ModrinthPacks" bucket the widget Modrinth page has always used, + * so anything fetched by that page already helps here too), or the + * built-in "modrinth" icon otherwise. Never starts a download - see + * the class comment. */ + QString resolveIconKey(const QString& slug, const QString& iconUrl) const; + + private: + QList m_packs; + + QString m_query; + QString m_sort; + QVariantList m_sortOptions; + QString m_gameVersion; + QString m_loader; + bool m_searching = false; + bool m_canFetchMore = false; + QString m_error; + + int m_nextOffset = 0; + bool m_restartPending = false; + NetJob::Ptr m_searchJob; + QByteArray m_searchResponse; + + ModrinthModpackDetail* m_detail = nullptr; + quint64 m_detailGeneration = 0; + bool m_bodyDone = true; + bool m_versionsDone = true; + NetJob::Ptr m_bodyJob; + NetJob::Ptr m_versionsJob; +}; diff --git a/launcher/modplatform/modrinth/ModrinthModpackModel_test.cpp b/launcher/modplatform/modrinth/ModrinthModpackModel_test.cpp new file mode 100644 index 00000000..30c986f9 --- /dev/null +++ b/launcher/modplatform/modrinth/ModrinthModpackModel_test.cpp @@ -0,0 +1,212 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include + +#include "modplatform/modrinth/ModrinthModpackModel.h" + +/* Both functions under test are static and touch neither the network nor + * LauncherContext, exactly so they can be exercised here with canned bytes + * - see the class comment on ModrinthModpackModel::parseSearchResults(). */ +class ModrinthModpackModelTest : public QObject +{ + Q_OBJECT + + private slots: + + void test_ParseSearchResults() + { + /* Plain (non-raw) string literals, concatenated: moc's lexer + * mishandles a *multi-line* raw string literal (R"(...)") in a + * Q_OBJECT class - it stops finding any class at all, hence the + * escaped quotes here rather than the more readable R"(...)" + * this codebase otherwise uses for a single-line JSON literal + * (see PackContents_test.cpp). */ + const QByteArray json = + "{" + " \"hits\": [" + " {" + " \"project_id\": \"abc12345\"," + " \"slug\": \"vault-hunters\"," + " \"title\": \"Vault Hunters\"," + " \"description\": \"A modpack about vaults.\"," + " \"author\": \"iskallia\"," + " \"icon_url\": \"https://cdn.modrinth.com/data/abc12345/icon.png\"," + " \"downloads\": 123456," + " \"follows\": 789," + " \"date_modified\": \"2026-01-01T00:00:00Z\"," + " \"latest_version\": \"ver1\"," + " \"versions\": [\"1.20.1\", \"1.20.2\"]," + " \"categories\": [\"adventure\", \"fabric\"]," + " \"display_categories\": [\"adventure\"]," + " \"featured_gallery\": \"https://cdn.modrinth.com/data/abc12345/gallery/hero.png\"," + " \"gallery\": [\"https://cdn.modrinth.com/data/abc12345/gallery/hero.png\"," + " \"https://cdn.modrinth.com/data/abc12345/gallery/second.png\"]," + " \"color\": 8383968" + " }," + " {" + " \"project_id\": \"def67890\"," + " \"slug\": \"no-optionals\"," + " \"title\": \"Minimal Pack\"" + " }," + " {" + " \"project_id\": \"ghijklmn\"" + " }" + " ]," + " \"total_hits\": 50" + "}"; + + int totalHits = -1; + const QList packs = + ModrinthModpackModel::parseSearchResults(json, totalHits); + + /* The third hit has no "title", which loadIndexedPack() requires + * - it must be skipped rather than crashing or aborting the rest + * of the page. */ + QCOMPARE(packs.size(), 2); + /* Read straight off the JSON's own "total_hits", independent of + * how many hits this one page happened to carry. */ + QCOMPARE(totalHits, 50); + + const Modrinth::IndexedPack& full = packs.at(0); + QCOMPARE(full.projectId, QString("abc12345")); + QCOMPARE(full.slug, QString("vault-hunters")); + QCOMPARE(full.name, QString("Vault Hunters")); + QCOMPARE(full.description, QString("A modpack about vaults.")); + QCOMPARE(full.author, QString("iskallia")); + QCOMPARE(full.iconUrl, + QString("https://cdn.modrinth.com/data/abc12345/icon.png")); + QCOMPARE(full.downloads, 123456); + QCOMPARE(full.follows, 789); + QCOMPARE(full.dateModified, QString("2026-01-01T00:00:00Z")); + QCOMPARE(full.latestVersion, QString("ver1")); + QCOMPARE(full.gameVersions, + QStringList({"1.20.1", "1.20.2"})); + QCOMPARE(full.categories, + QStringList({"adventure", "fabric"})); + QCOMPARE(full.displayCategories, QStringList({"adventure"})); + QCOMPARE(full.featuredGalleryUrl, + QString("https://cdn.modrinth.com/data/abc12345/gallery/" + "hero.png")); + QCOMPARE(full.galleryUrls, + QStringList({"https://cdn.modrinth.com/data/abc12345/" + "gallery/hero.png", + "https://cdn.modrinth.com/data/abc12345/" + "gallery/second.png"})); + QCOMPARE(full.color, 8383968); + + const Modrinth::IndexedPack& minimal = packs.at(1); + QCOMPARE(minimal.projectId, QString("def67890")); + QCOMPARE(minimal.name, QString("Minimal Pack")); + QCOMPARE(minimal.author, QString()); + QCOMPARE(minimal.downloads, 0); + QCOMPARE(minimal.follows, 0); + QVERIFY(minimal.gameVersions.isEmpty()); + QVERIFY(minimal.categories.isEmpty()); + /* No "display_categories", "featured_gallery", "gallery" or + * "color" at all - every addition above must default cleanly + * rather than require the field to be present. */ + QVERIFY(minimal.displayCategories.isEmpty()); + QVERIFY(minimal.featuredGalleryUrl.isEmpty()); + QVERIFY(minimal.galleryUrls.isEmpty()); + QCOMPARE(minimal.color, -1); + } + + void test_ParseSearchResultsOnGarbageIsEmpty() + { + int totalHits = 0; + const QList packs = + ModrinthModpackModel::parseSearchResults("not json at all", + totalHits); + QVERIFY(packs.isEmpty()); + } + + void test_ParseVersionsJson() + { + const QByteArray json = + "[" + " {" + " \"id\": \"ver1\"," + " \"project_id\": \"abc12345\"," + " \"name\": \"Release 1.2.3\"," + " \"version_number\": \"1.2.3\"," + " \"game_versions\": [\"1.20.1\"]," + " \"loaders\": [\"forge\"]," + " \"date_published\": \"2026-01-01T00:00:00Z\"," + " \"featured\": true," + " \"files\": [" + " {" + " \"primary\": true," + " \"url\": \"https://cdn.modrinth.com/data/abc12345/versions/ver1/pack.mrpack\"," + " \"size\": 1024," + " \"hashes\": {\"sha1\": \"deadbeef\"}" + " }" + " ]" + " }," + " {" + " \"id\": \"ver2\"," + " \"version_number\": \"1.2.2\"," + " \"game_versions\": [\"1.19.2\", \"1.19.4\"]," + " \"loaders\": [\"forge\", \"neoforge\"]," + " \"date_published\": \"2025-06-01T00:00:00Z\"," + " \"files\": [" + " {" + " \"url\": \"https://cdn.modrinth.com/data/abc12345/versions/ver2/pack.mrpack\"" + " }" + " ]" + " }" + "]"; + + const QVariantList versions = + ModrinthModpackModel::parseVersionsJson(json, "abc12345"); + QCOMPARE(versions.size(), 2); + + const QVariantMap first = versions.at(0).toMap(); + QCOMPARE(first.value("id").toString(), QString("ver1")); + QCOMPARE(first.value("name").toString(), QString("Release 1.2.3")); + QCOMPARE(first.value("versionNumber").toString(), QString("1.2.3")); + QCOMPARE(first.value("gameVersions").toStringList(), + QStringList({"1.20.1"})); + QCOMPARE(first.value("loaders").toStringList(), + QStringList({"forge"})); + QCOMPARE(first.value("datePublished").toString(), + QString("2026-01-01T00:00:00Z")); + QCOMPARE(first.value("downloadUrl").toString(), + QString("https://cdn.modrinth.com/data/abc12345/versions/" + "ver1/pack.mrpack")); + QCOMPARE(first.value("featured").toBool(), true); + + /* No "primary" flag, but a single file - loadIndexedPackVersions() + * picks it anyway, same as the widget browser has always done. */ + const QVariantMap second = versions.at(1).toMap(); + QCOMPARE(second.value("id").toString(), QString("ver2")); + QCOMPARE(second.value("gameVersions").toStringList(), + QStringList({"1.19.2", "1.19.4"})); + QCOMPARE(second.value("loaders").toStringList(), + QStringList({"forge", "neoforge"})); + QCOMPARE(second.value("featured").toBool(), false); + QCOMPARE(second.value("downloadUrl").toString(), + QString("https://cdn.modrinth.com/data/abc12345/versions/" + "ver2/pack.mrpack")); + } +}; + +QTEST_GUILESS_MAIN(ModrinthModpackModelTest) + +#include "ModrinthModpackModel_test.moc" diff --git a/launcher/modplatform/modrinth/ModrinthPackExportTask.cpp b/launcher/modplatform/modrinth/ModrinthPackExportTask.cpp index 84e7df2a..779b44c0 100644 --- a/launcher/modplatform/modrinth/ModrinthPackExportTask.cpp +++ b/launcher/modplatform/modrinth/ModrinthPackExportTask.cpp @@ -35,8 +35,8 @@ #include #include -#include "Application.h" #include "archive/ExportToZipTask.h" +#include "core/LauncherContext.h" #include "minecraft/MinecraftInstance.h" #include "minecraft/PackProfile.h" #include "minecraft/mod/ModMetadataIndex.h" @@ -329,7 +329,7 @@ void ModrinthPackExportTask::lookUpPendingFiles() for (const PendingFile& pending : m_pending) { auto response = std::make_shared(); auto* job = new NetJob(QString("MR::ExportLookup(%1)").arg(pending.path), - APPLICATION->network()); + LAUNCHER->network()); job->addNetAction(Net::Download::makeByteArray( ModrinthApi::versionByHashUrl(pending.sha1), response.get())); diff --git a/launcher/modplatform/modrinth/ModrinthPackIndex.cpp b/launcher/modplatform/modrinth/ModrinthPackIndex.cpp index 364d1c33..cdd7d9ab 100644 --- a/launcher/modplatform/modrinth/ModrinthPackIndex.cpp +++ b/launcher/modplatform/modrinth/ModrinthPackIndex.cpp @@ -34,6 +34,40 @@ void Modrinth::loadIndexedPack(Modrinth::IndexedPack& pack, QJsonObject& obj) pack.downloads = Json::ensureInteger(obj, "downloads", 0); pack.iconUrl = Json::ensureString(obj, "icon_url", ""); + + /* Only present on a search hit, which is the only reply this + * function is fed today - see the header comment on IndexedPack. */ + pack.follows = Json::ensureInteger(obj, "follows", 0); + pack.dateModified = Json::ensureString(obj, "date_modified", ""); + pack.latestVersion = Json::ensureString(obj, "latest_version", ""); + + QStringList gameVersions; + for (const auto& version : Json::ensureArray(obj, "versions")) { + gameVersions.append(version.toString()); + } + pack.gameVersions = gameVersions; + + QStringList categories; + for (const auto& category : Json::ensureArray(obj, "categories")) { + categories.append(category.toString()); + } + pack.categories = categories; + + QStringList displayCategories; + for (const auto& category : Json::ensureArray(obj, "display_categories")) { + displayCategories.append(category.toString()); + } + pack.displayCategories = displayCategories; + + pack.featuredGalleryUrl = + Json::ensureString(obj, "featured_gallery", QString()); + QStringList galleryUrls; + for (const auto& image : Json::ensureArray(obj, "gallery")) { + galleryUrls.append(image.toString()); + } + pack.galleryUrls = galleryUrls; + + pack.color = Json::ensureInteger(obj, "color", -1); } void Modrinth::loadIndexedPackVersions(Modrinth::IndexedPack& pack, @@ -50,8 +84,13 @@ void Modrinth::loadIndexedPackVersions(Modrinth::IndexedPack& pack, version.versionNumber = Json::requireString(obj, "version_number"); auto gameVersions = Json::ensureArray(obj, "game_versions"); - if (!gameVersions.isEmpty()) { - version.mcVersion = gameVersions.first().toString(); + QStringList gameVersionList; + for (auto gameVersion : gameVersions) { + gameVersionList.append(gameVersion.toString()); + } + version.gameVersions = gameVersionList; + if (!gameVersionList.isEmpty()) { + version.mcVersion = gameVersionList.first(); } auto loaders = Json::ensureArray(obj, "loaders"); @@ -59,8 +98,12 @@ void Modrinth::loadIndexedPackVersions(Modrinth::IndexedPack& pack, for (auto loader : loaders) { loaderList.append(loader.toString()); } + version.loaderList = loaderList; version.loaders = loaderList.join(", "); + version.datePublished = Json::ensureString(obj, "date_published", ""); + version.featured = Json::ensureBoolean(obj, "featured", false); + auto files = Json::ensureArray(obj, "files"); for (auto fileRaw : files) { auto fileObj = fileRaw.toObject(); diff --git a/launcher/modplatform/modrinth/ModrinthPackIndex.h b/launcher/modplatform/modrinth/ModrinthPackIndex.h index 16f03d96..11b5cb24 100644 --- a/launcher/modplatform/modrinth/ModrinthPackIndex.h +++ b/launcher/modplatform/modrinth/ModrinthPackIndex.h @@ -22,6 +22,7 @@ #include #include #include +#include #include namespace Modrinth @@ -37,6 +38,14 @@ namespace Modrinth int downloadSize = 0; QString sha1; QString loaders; + + /* Full lists, for consumers that need more than the single + * mcVersion / comma-joined loaders above (e.g. a QML detail + * view) - see loadIndexedPackVersions(). */ + QStringList gameVersions; + QStringList loaderList; + QString datePublished; + bool featured = false; }; struct IndexedPack { @@ -48,6 +57,34 @@ namespace Modrinth QString iconUrl; int downloads = 0; + /* From the search hit only (see loadIndexedPack()) - cheap + * extras a QML browse page can show without another round + * trip. Left at their defaults when parsing anything other + * than a search hit. */ + int follows = 0; + QString dateModified; + QStringList gameVersions; + QString latestVersion; + QStringList categories; + /* The subset of `categories` Modrinth marks for display on a + * card ("display_categories") - shorter and curated, unlike + * `categories` which also carries filter-only tags. Empty when + * the hit did not say, in which case a caller should fall back + * to `categories` itself. */ + QStringList displayCategories; + /* A cover image for the card grid: the search hit's own + * "featured_gallery" (one URL, or empty if the project marked + * none as featured) falling back to the first of "gallery" + * (plain URLs on a search hit - richer objects only come back + * from the project endpoint, see fetchDetailBody()). Empty + * when the project has no gallery at all. */ + QString featuredGalleryUrl; + QStringList galleryUrls; + /* Modrinth's automatically generated accent colour for the + * project, as 0xRRGGBB; -1 when the hit had none (JSON + * null or missing, e.g. a project with no icon yet). */ + int color = -1; + bool versionsLoaded = false; QVector versions; }; diff --git a/launcher/modplatform/technic/SingleZipPackInstallTask.cpp b/launcher/modplatform/technic/SingleZipPackInstallTask.cpp index 1b1b564b..7bbcdf80 100644 --- a/launcher/modplatform/technic/SingleZipPackInstallTask.cpp +++ b/launcher/modplatform/technic/SingleZipPackInstallTask.cpp @@ -26,7 +26,7 @@ #include "TechnicPackProcessor.h" #include "FileSystem.h" -#include "Application.h" +#include "core/LauncherContext.h" Technic::SingleZipPackInstallTask::SingleZipPackInstallTask( const QUrl& sourceUrl, const QString& minecraftVersion) @@ -48,10 +48,10 @@ void Technic::SingleZipPackInstallTask::executeTask() setStatus(tr("Downloading modpack:\n%1").arg(m_sourceUrl.toString())); const QString path = m_sourceUrl.host() + '/' + m_sourceUrl.path(); - auto entry = APPLICATION->metacache()->resolveEntry("general", path); + auto entry = LAUNCHER->metacache()->resolveEntry("general", path); entry->setStale(true); m_archiveEntry = entry; - m_filesNetJob = new NetJob(tr("Modpack download"), APPLICATION->network()); + m_filesNetJob = new NetJob(tr("Modpack download"), LAUNCHER->network()); m_filesNetJob->addNetAction(Net::Download::makeCached(m_sourceUrl, entry)); m_archivePath = entry->getFullPath(); auto job = m_filesNetJob.get(); @@ -139,7 +139,7 @@ void Technic::SingleZipPackInstallTask::extractFinished() if (!QFile::remove(m_archivePath)) { qWarning() << "Could not remove" << m_archivePath; } - APPLICATION->metacache()->evictEntry(m_archiveEntry); + LAUNCHER->metacache()->evictEntry(m_archiveEntry); m_archiveEntry.reset(); } emitFailed(tr("Failed to extract modpack. The downloaded archive is " diff --git a/launcher/net/JsonPost.cpp b/launcher/net/JsonPost.cpp index 8326aa8a..3106b767 100644 --- a/launcher/net/JsonPost.cpp +++ b/launcher/net/JsonPost.cpp @@ -24,9 +24,9 @@ #include -#include "Application.h" #include "BuildConfig.h" #include "modplatform/flame/FlameApi.h" +#include "core/LauncherContext.h" namespace Net { @@ -62,7 +62,7 @@ namespace Net BuildConfig.CURSEFORGE_API_KEY.toUtf8()); } - m_reply.reset(APPLICATION->network()->post(request, m_body)); + m_reply.reset(LAUNCHER->network()->post(request, m_body)); connect(m_reply.get(), &QNetworkReply::finished, this, &JsonPost::requestFinished); } diff --git a/launcher/net/MetaCacheSink.cpp b/launcher/net/MetaCacheSink.cpp index 2c959469..1e968c6f 100644 --- a/launcher/net/MetaCacheSink.cpp +++ b/launcher/net/MetaCacheSink.cpp @@ -21,7 +21,7 @@ #include #include #include "FileSystem.h" -#include "Application.h" +#include "core/LauncherContext.h" namespace Net { @@ -72,7 +72,7 @@ namespace Net m_entry->setLocalChangedTimestamp( output_file_info.lastModified().toUTC().toMSecsSinceEpoch()); m_entry->setStale(false); - APPLICATION->metacache()->updateEntry(m_entry); + LAUNCHER->metacache()->updateEntry(m_entry); return Job_Finished; } diff --git a/launcher/net/PasteUpload.cpp b/launcher/net/PasteUpload.cpp index 2c9581c7..ef1cefb4 100644 --- a/launcher/net/PasteUpload.cpp +++ b/launcher/net/PasteUpload.cpp @@ -19,7 +19,7 @@ #include "PasteUpload.h" #include "BuildConfig.h" -#include "Application.h" +#include "core/LauncherContext.h" #include "Logging.h" #include @@ -97,8 +97,7 @@ static QString applyFilters(QString logContent) return logContent; } -PasteUpload::PasteUpload(QWidget* window, QString text, QString key) - : m_window(window) +PasteUpload::PasteUpload(QString text, QString key) { m_key = key; QString censoredText = applyFilters(text); @@ -133,7 +132,7 @@ void PasteUpload::executeTask() QByteArray::number(m_jsonContent.size())); request.setRawHeader("X-Auth-Token", m_key.toStdString().c_str()); - QNetworkReply* rep = APPLICATION->network()->post(request, m_jsonContent); + QNetworkReply* rep = LAUNCHER->network()->post(request, m_jsonContent); m_reply = std::shared_ptr(rep); setStatus(tr("Uploading to paste.ee")); diff --git a/launcher/net/PasteUpload.h b/launcher/net/PasteUpload.h index 38b591e4..cec6dc48 100644 --- a/launcher/net/PasteUpload.h +++ b/launcher/net/PasteUpload.h @@ -27,7 +27,7 @@ class PasteUpload : public Task { Q_OBJECT public: - PasteUpload(QWidget* window, QString text, QString key = "public"); + PasteUpload(QString text, QString key = "public"); virtual ~PasteUpload(); QString pasteLink() @@ -54,7 +54,6 @@ class PasteUpload : public Task private: bool parseResult(QJsonDocument doc); QString m_error; - QWidget* m_window; QString m_pasteID; QString m_pasteLink; QString m_key; diff --git a/launcher/notifications/NotificationChecker.cpp b/launcher/notifications/NotificationChecker.cpp index 0b2062ee..6ec7c72d 100644 --- a/launcher/notifications/NotificationChecker.cpp +++ b/launcher/notifications/NotificationChecker.cpp @@ -26,7 +26,7 @@ #include "net/Download.h" -#include "Application.h" +#include "core/LauncherContext.h" NotificationChecker::NotificationChecker(QObject* parent) : QObject(parent) {} @@ -70,9 +70,9 @@ void NotificationChecker::checkForNotifications() return; } m_checkJob = - new NetJob("Checking for notifications", APPLICATION->network()); + new NetJob("Checking for notifications", LAUNCHER->network()); auto entry = - APPLICATION->metacache()->resolveEntry("root", "notifications.json"); + LAUNCHER->metacache()->resolveEntry("root", "notifications.json"); entry->setStale(true); m_checkJob->addNetAction( m_download = Net::Download::makeCached(m_notificationsUrl, entry)); diff --git a/launcher/plugin/MMCOFormat.h b/launcher/plugin/MMCOFormat.h index a600a173..1aa0a69f 100644 --- a/launcher/plugin/MMCOFormat.h +++ b/launcher/plugin/MMCOFormat.h @@ -64,7 +64,7 @@ #define MMCO_MAGIC 0x4D4D434F #define MMCO_VERSION "10.0.0" -#define MMCO_ABI_VERSION 4 +#define MMCO_ABI_VERSION 5 #define MMCO_EXTENSION ".mmco" /* Magic value that identifies the GPG signature trailer at the end of a diff --git a/launcher/plugin/PluginAPI.h b/launcher/plugin/PluginAPI.h index 8dc459a2..83f4266e 100644 --- a/launcher/plugin/PluginAPI.h +++ b/launcher/plugin/PluginAPI.h @@ -47,9 +47,43 @@ typedef void (*MMCOMenuActionCallback)(void* user_data); typedef void (*MMCODirEntryCallback)(void* user_data, const char* entry_name, int is_dir); -/* UI widget callback types */ -typedef void (*MMCOButtonCallback)(void* user_data); -typedef void (*MMCOTreeSelectionCallback)(void* user_data, int row); +/* + * MMCOUiEventCallback — ABI 5. Single event callback shape for every + * declarative UI surface (ui_surface_create) and for the declarative + * tray menu (tray_set_menu). + * + * surface_id — stable string identifying which surface/menu this + * event came from (a plugin with several surfaces + * sharing one callback distinguishes them by this). + * For tray-menu events this is always "tray". + * node_id — the plugin-assigned id of the node that fired. + * event — "click" (button/link/menu item), "change" (toggle/ + * text_field/number_field/choice — value_json is the + * new value), or "select"/"activate" (list row — + * value_json is the row id). + * value_json — event-specific payload, or "" if not applicable. + */ +typedef void (*MMCOUiEventCallback)(void* user_data, const char* surface_id, + const char* node_id, const char* event, + const char* value_json); + +/* + * MMCOUiAnchor — ABI 5. Where a declarative UI surface (ui_surface_create) + * is displayed: + * + * MMCO_UI_ANCHOR_GLOBAL_SETTINGS — stacked as a titled section inside + * the host's single "Plugins" page in the global Settings dialog. + * MMCO_UI_ANCHOR_INSTANCE_PAGE — its own page in the instance window + * (anchor_context = instance id). + * MMCO_UI_ANCHOR_INSTANCE_SETTINGS — stacked as a titled section inside + * a "Plugins" group on that instance's Settings page + * (anchor_context = instance id). + */ +enum MMCOUiAnchor { + MMCO_UI_ANCHOR_GLOBAL_SETTINGS = 0, + MMCO_UI_ANCHOR_INSTANCE_PAGE = 1, + MMCO_UI_ANCHOR_INSTANCE_SETTINGS = 2 +}; /* * Tray-icon activation reason — passed to MMCOTrayActivationCallback. @@ -245,62 +279,6 @@ struct MMCOContext { /* Returns 1=Yes, 0=No */ int (*ui_confirm_dialog)(void* mh, const char* title, const char* message); - /* DEPRECATED, no-op since the instance sidebar was fixed to a set list - * of instance-wide commands. Always returns 0 and registers nothing; - * the slot is kept only so existing modules still link. - * Use ui_register_instance_page() instead -- an instance window page is - * where per-instance plugin UI belongs. */ - int (*ui_register_instance_action)(void* mh, const char* text, - const char* tooltip, - const char* icon_name, - const char* page_id); - - /* DEPRECATED, no-op. See ui_register_instance_action above. */ - int (*ui_register_instance_action_cb)(void* mh, const char* text, - const char* tooltip, - const char* icon_name, - void (*cb)(void* ud), void* ud); - - /* Create a page widget. Returns opaque page handle. */ - void* (*ui_page_create)(void* mh, const char* page_id, - const char* display_name, const char* icon_name); - - /* Add the created page to the page list from a hook event. */ - int (*ui_page_add_to_list)(void* mh, void* page_handle, - void* page_list_handle); - - /* Layouts: type 0=vertical, 1=horizontal */ - void* (*ui_layout_create)(void* mh, void* parent, int type); - int (*ui_layout_add_widget)(void* mh, void* layout, void* widget); - int (*ui_layout_add_layout)(void* mh, void* parent_layout, - void* child_layout); - int (*ui_layout_add_spacer)(void* mh, void* layout, int horizontal); - int (*ui_page_set_layout)(void* mh, void* page, void* layout); - - /* Button */ - void* (*ui_button_create)(void* mh, void* parent, const char* text, - const char* icon_name, - MMCOButtonCallback callback, void* user_data); - int (*ui_button_set_enabled)(void* mh, void* button, int enabled); - int (*ui_button_set_text)(void* mh, void* button, const char* text); - - /* Label */ - void* (*ui_label_create)(void* mh, void* parent, const char* text); - int (*ui_label_set_text)(void* mh, void* label, const char* text); - - /* Tree widget (table-like list with columns) */ - void* (*ui_tree_create)(void* mh, void* parent, const char** column_names, - int column_count, - MMCOTreeSelectionCallback on_select, - void* user_data); - int (*ui_tree_clear)(void* mh, void* tree); - int (*ui_tree_add_row)(void* mh, void* tree, const char** values, - int col_count); - int (*ui_tree_selected_row)(void* mh, void* tree); - int (*ui_tree_set_row_data)(void* mh, void* tree, int row, int64_t data); - int64_t (*ui_tree_get_row_data)(void* mh, void* tree, int row); - int (*ui_tree_row_count)(void* mh, void* tree); - const char* (*get_app_version)(void* mh); const char* (*get_app_name)(void* mh); int64_t (*get_timestamp)(void* mh); @@ -372,9 +350,10 @@ struct MMCOContext { /* S18 — Plugin icon set (ABI 2+) */ /* Resolve a logical icon name from the calling module's bundled - * icon set into a Qt resource path that can be passed to the - * ui_* widget creators above (which forward to QIcon::fromTheme() - * and QIcon::QIcon(QString)). + * icon set into a Qt resource path that can be passed to any other + * icon-name parameter in this context (ui_surface_create's + * icon_name, tray_create/tray_set_icon, a node's "icon" prop) — + * they all forward to QIcon::fromTheme() / QIcon::QIcon(QString). * * Returns a string of the form ":/plugins//" or * nullptr if the module did not declare an icon_set_resource or @@ -383,19 +362,19 @@ struct MMCOContext { * * Example: * const char* iconPath = ctx->ui_plugin_icon(MMCO_MH, "settings"); - * ctx->ui_button_create(MMCO_MH, parent, "Settings", iconPath, - * cb, ud); + * ctx->ui_surface_create(MMCO_MH, MMCO_UI_ANCHOR_GLOBAL_SETTINGS, + * nullptr, "Settings", iconPath, doc, cb, ud); */ const char* (*ui_plugin_icon)(void* mh, const char* name); /* ─────────────────────────────────────────────────────────────── * S19 — System Tray (ABI 2+, additive) * - * Lets a plugin own one or more QSystemTrayIcon instances and a - * detached QMenu tree to attach to them. Memory is owned by - * PluginManager — every handle handed out here is released either - * on tray_destroy / menu_destroy, or automatically when the owning - * module is unloaded (no leaks at shutdown). + * Lets a plugin own one or more QSystemTrayIcon instances and attach + * a declarative menu (see tray_set_menu, ABI 5) to them. Memory is + * owned by PluginManager — every tray handle handed out here is + * released either on tray_destroy, or automatically when the + * owning module is unloaded (no leaks at shutdown). * * All handles are opaque pointers — never cast them yourself. * Returns from creation functions: nullptr on failure (e.g. system @@ -419,8 +398,9 @@ struct MMCOContext { /* Returns 1 if QSystemTrayIcon::isSystemTrayAvailable() is true. */ int (*tray_is_available)(void* mh); - /* Update icon — accepts the same names as ui_button_create() (theme - * names + ":/..." Qt resource paths). */ + /* Update icon — accepts the same names as every other icon-name + * parameter in this context (theme names + ":/..." Qt resource + * paths). */ int (*tray_set_icon)(void* mh, void* tray_handle, const char* icon_name); int (*tray_set_tooltip)(void* mh, void* tray_handle, const char* tooltip); int (*tray_set_visible)(void* mh, void* tray_handle, int visible); @@ -433,42 +413,24 @@ struct MMCOContext { int (*tray_show_message)(void* mh, void* tray_handle, const char* title, const char* message, int icon_type, int msecs); - /* Attach a menu to the tray icon — the menu pops up on right-click. - * Pass nullptr to detach. The plugin retains ownership of the menu; - * the tray references it. */ - int (*tray_set_menu)(void* mh, void* tray_handle, void* menu_handle); + /* Attach a declarative menu to the tray icon — the menu pops up on + * right-click (ABI 5). `json_menu_doc` is a small "mmco-ui/1" tree + * whose root's children are `button` (menu item), `separator`, or + * `section` (submenu, itself containing more button/separator/ + * section children) nodes. Clicking an item fires `cb` with + * event="click" and node_id = the item's id. Pass json_menu_doc = + * nullptr to detach the menu. `cb`/`user_data` replace the previous + * per-action MMCOMenuActionCallback plumbing — one callback serves + * every item in the doc. Re-call with a freshly built document to + * rebuild the menu (e.g. on MMCO_HOOK_INSTANCE_CREATED/REMOVED). */ + int (*tray_set_menu)(void* mh, void* tray_handle, const char* json_menu_doc, + MMCOUiEventCallback cb, void* user_data); /* Register an activation callback (fires on left/middle/double click). * Pass cb=nullptr to clear. Only one callback per tray. */ int (*tray_set_activation_cb)(void* mh, void* tray_handle, MMCOTrayActivationCallback cb, void* ud); - /* Create a standalone QMenu owned by the plugin. Compatible with - * ui_add_menu_item() and tray_set_menu(). */ - void* (*tray_menu_create)(void* mh); - int (*tray_menu_destroy)(void* mh, void* menu_handle); - int (*tray_menu_clear)(void* mh, void* menu_handle); - int (*tray_menu_add_separator)(void* mh, void* menu_handle); - /* Add an entry. Returns opaque action handle (or nullptr). */ - void* (*tray_menu_add_action)(void* mh, void* menu_handle, - const char* label, const char* icon_name, - MMCOMenuActionCallback cb, void* ud); - int (*tray_menu_action_set_enabled)(void* mh, void* action_handle, - int enabled); - int (*tray_menu_action_set_text)(void* mh, void* action_handle, - const char* text); - - /* Create a nested submenu under `parent_menu` with the given label. - * Returns an opaque QMenu* compatible with the rest of the - * tray_menu_* family (add_action, clear, add_separator, etc). - * - * The child menu is parented to the parent menu, so deleting the - * parent will sweep the child — plugins do NOT need to call - * tray_menu_destroy() on submenus they obtained this way. - * Returns nullptr on failure. */ - void* (*tray_menu_add_submenu)(void* mh, void* parent_menu, - const char* label, const char* icon_name); - /* ─────────────────────────────────────────────────────────────── * S20 — Main-window helpers (ABI 2+, additive) * ─────────────────────────────────────────────────────────────── */ @@ -844,4 +806,59 @@ struct MMCOContext { int (*progress_report)(void* handle, const char* status, const char* details, int64_t current, int64_t total); + + /* ─────────────────────────────────────────────────────────────── + * S33 — Declarative UI surfaces (ABI 5) + * + * Replaces the S13 imperative widget builder (ui_page_create / + * ui_layout_* / ui_button_* / ui_label_* / ui_tree_*, all gone as + * of ABI 5) and the allWidgets()/findChild() settings-injection + * pattern. A plugin describes a small widget tree as a JSON + * document (the "mmco-ui/1" format — see PluginUiRenderer.h) and + * the host renders and owns the real QWidget tree; no QWidget* is + * ever handed back to a plugin. + * + * ui_surface_create — build and display a surface at the given + * anchor. `anchor_context` is nullptr for GLOBAL_SETTINGS, or + * the instance id for INSTANCE_PAGE / INSTANCE_SETTINGS. + * `title`/`icon_name` label the surface (page title for + * INSTANCE_PAGE, section title otherwise). `cb`/`user_data` + * receive every click/change/select event from nodes in the + * doc (see MMCOUiEventCallback). Returns an opaque surface + * handle, or nullptr on failure (bad JSON, unknown anchor). + * ui_surface_update — replace the whole document. + * ui_surface_set — patch one node's `props` (e.g. a toggle's + * value, a button's enabled state) without touching the rest + * of the tree. + * ui_surface_set_rows — replace a `list` node's `rows` only; + * the cheap refresh path, replacing the old + * ui_tree_clear + ui_tree_add_row loop. + * ui_surface_destroy — tear down a surface early. Every surface + * a module still owns is also torn down automatically when + * the module unloads. + * + * All four mutators return 0 on success, -1 on failure (unknown + * surface handle, malformed JSON, or unknown node_id). + * ─────────────────────────────────────────────────────────────── */ + void* (*ui_surface_create)(void* mh, int anchor, const char* anchor_context, + const char* title, const char* icon_name, + const char* json_doc, MMCOUiEventCallback cb, + void* user_data); + int (*ui_surface_update)(void* mh, void* surface, const char* json_doc); + int (*ui_surface_set)(void* mh, void* surface, const char* node_id, + const char* json_props); + int (*ui_surface_set_rows)(void* mh, void* surface, const char* node_id, + const char* json_rows); + int (*ui_surface_destroy)(void* mh, void* surface); + + /* Blocking: shows a small transient doc (must contain at least one + * `button`) parented to the active window, pumps a local event + * loop (same pattern as S26's account_skin_upload), and returns + * once a button fires. `out_result_json` receives + * {"button":"","fields":{"":"", ...}} — one entry + * per interactive node's current value at the time the button was + * clicked, truncated to fit `out_buf_size`. Returns 0 on a button + * click, -1 on bad arguments / malformed JSON. */ + int (*ui_modal_run)(void* mh, const char* title, const char* json_doc, + char* out_result_json, int out_buf_size); }; diff --git a/launcher/plugin/PluginAuthRequestDecorator.cpp b/launcher/plugin/PluginAuthRequestDecorator.cpp new file mode 100644 index 00000000..9b4faad9 --- /dev/null +++ b/launcher/plugin/PluginAuthRequestDecorator.cpp @@ -0,0 +1,80 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "plugin/PluginAuthRequestDecorator.h" + +#include +#include + +#include "plugin/PluginHooks.h" +#include "plugin/PluginManager.h" + +PluginAuthRequestDecorator::PluginAuthRequestDecorator(PluginManager* manager) + : m_manager(manager) +{ +} + +bool PluginAuthRequestDecorator::dispatchAuthRequest(QNetworkRequest& request, + const QByteArray& body, + const char* method) +{ + if (!m_manager) + return false; + + /* The add_header callback closes over the request reference and + * appends raw headers. We keep it as a plain C function pointer with a + * sidecar state struct so the closure can survive the C ABI boundary. */ + struct HeaderCtx { + QNetworkRequest* req; + }; + HeaderCtx hctx{&request}; + + auto add_header_fn = [](void* handle, const char* key, + const char* value) -> int { + if (!handle || !key || !value) + return -1; + auto* h = static_cast(handle); + h->req->setRawHeader(QByteArray(key), QByteArray(value)); + return 0; + }; + + const QByteArray urlUtf8 = request.url().toString().toUtf8(); + + MMCOAuthRequestEvent ev{}; + ev.url = urlUtf8.constData(); + ev.method = method; + ev.body = body.isEmpty() ? nullptr : body.constData(); + ev.body_size = body.size(); + ev.redirect_url = nullptr; + ev.request_handle = &hctx; + ev.add_header = add_header_fn; + + const bool cancelled = + m_manager->dispatchHook(MMCO_HOOK_AUTH_REQUEST, &ev); + if (cancelled) + return true; + + if (ev.redirect_url && *ev.redirect_url) { + const QUrl rewritten = + QUrl::fromUserInput(QString::fromUtf8(ev.redirect_url)); + if (rewritten.isValid()) + request.setUrl(rewritten); + } + return false; +} diff --git a/launcher/plugin/PluginAuthRequestDecorator.h b/launcher/plugin/PluginAuthRequestDecorator.h new file mode 100644 index 00000000..5037cd29 --- /dev/null +++ b/launcher/plugin/PluginAuthRequestDecorator.h @@ -0,0 +1,44 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "core/AuthRequestDecorator.h" + +class PluginManager; + +/* + * Feeds outgoing authentication requests through MMCO_HOOK_AUTH_REQUEST. + * + * This used to live inside AuthRequest.cpp, which meant the authentication + * code reached PluginManager directly -- and PluginManager builds + * plugin-supplied user interface, so that single call pulled QtWidgets into + * the core. The hook logic is unchanged; only its address moved. + */ +class PluginAuthRequestDecorator final : public AuthRequestDecorator +{ + public: + explicit PluginAuthRequestDecorator(PluginManager* manager); + + bool dispatchAuthRequest(QNetworkRequest& request, const QByteArray& body, + const char* method) override; + + private: + PluginManager* m_manager; +}; diff --git a/launcher/plugin/PluginHooks.h b/launcher/plugin/PluginHooks.h index 48b25ee4..89862a1c 100644 --- a/launcher/plugin/PluginHooks.h +++ b/launcher/plugin/PluginHooks.h @@ -301,12 +301,18 @@ struct MMCOGlobalSettingsPagesEvent { * Handles are valid for the lifetime of the main window. Plugins should * NOT take ownership of them or delete them. Cast them through Qt's * normal qobject_cast<>() to the documented concrete type. + * + * Under the QML shell (MESHMC_QML_UI / MeshMC_QML_UI) there is no + * MainWindow and none of these widgets exist: the hook still fires once, + * right after the shell's root window is shown, but every field below is + * null. Use main_window_show/hide/is_visible and + * main_window_install_close_filter instead, which work under either UI. */ struct MMCOUiMainReadyPayload { - void* main_window; /* Opaque: QMainWindow* (MainWindow*) */ - void* news_toolbar; /* Opaque: QToolBar* */ - void* more_news_action; /* Opaque: QAction* */ - void* news_label_button; /* Opaque: QToolButton* */ + void* main_window; /* Opaque: QMainWindow* (MainWindow*); null under the QML shell */ + void* news_toolbar; /* Opaque: QToolBar*; null under the QML shell */ + void* more_news_action; /* Opaque: QAction*; null under the QML shell */ + void* news_label_button; /* Opaque: QToolButton*; null under the QML shell */ }; /* diff --git a/launcher/plugin/PluginLoader.cpp b/launcher/plugin/PluginLoader.cpp index 651c7f81..78db053d 100644 --- a/launcher/plugin/PluginLoader.cpp +++ b/launcher/plugin/PluginLoader.cpp @@ -268,6 +268,16 @@ PluginMetadata PluginLoader::loadModule(const QString& path) const return meta; } + // Capture identity fields before the ABI gate below. `name` and + // `version` sit in MMCOModuleInfo ahead of the "ABI 2 fields" block + // (see MMCOFormat.h) and have kept the same offsets since the + // format's first revision, so reading them here does not trust + // anything beyond what the magic check above already trusts. A + // module we are about to refuse for its ABI still deserves a name + // and version in the plugins dialog instead of just vanishing. + meta.name = QString::fromUtf8(info->name ? info->name : ""); + meta.version = QString::fromUtf8(info->version ? info->version : ""); + // Validate ABI version. // // Range-accept: any ABI from MMCO_ABI_VERSION_MIN through the @@ -277,20 +287,30 @@ PluginMetadata PluginLoader::loadModule(const QString& path) const // those slots are appended to MMCOContext (never reordered), so a // stale plugin reads the slots it knows about and ignores the // rest. Reject anything newer than the launcher knows. - constexpr uint32_t MMCO_ABI_VERSION_MIN = 2; + constexpr uint32_t MMCO_ABI_VERSION_MIN = 5; if (info->abi_version < MMCO_ABI_VERSION_MIN || info->abi_version > MMCO_ABI_VERSION) { qWarning() << "[PluginLoader]" << path << "ABI version mismatch:" << info->abi_version << "(host supports" << MMCO_ABI_VERSION_MIN << ".." << MMCO_ABI_VERSION << ")"; - unloadModule(meta); + + // Unlike the magic/missing-symbol failures above, this file IS a + // real MMCO module — just one built for an ABI this launcher + // cannot safely initialise. Keep it "loaded" (the library handle + // stays open, same as the signature-policy disables further + // down) so the plugins dialog still shows its name/version and + // why it was refused, instead of the file silently disappearing. + meta.loaded = true; + meta.disabled = true; + meta.disableReason = + classifyAbiMismatch(meta.name, path, info->abi_version, + MMCO_ABI_VERSION_MIN, MMCO_ABI_VERSION, + meta.disableDetail); return meta; } meta.moduleInfo = info; - meta.name = QString::fromUtf8(info->name ? info->name : ""); - meta.version = QString::fromUtf8(info->version ? info->version : ""); meta.author = QString::fromUtf8(info->author ? info->author : ""); meta.description = QString::fromUtf8(info->description ? info->description : ""); @@ -431,6 +451,31 @@ void PluginLoader::verifySignatureAndPolicy(PluginMetadata& meta) } } +PluginDisableReason +PluginLoader::classifyAbiMismatch(const QString& moduleName, + const QString& fallbackLabel, + uint32_t builtForAbi, uint32_t abiMin, + uint32_t abiMax, QString& outDetail) +{ + const QString label = moduleName.isEmpty() ? fallbackLabel : moduleName; + + // QCoreApplication::translate() rather than tr(): PluginLoader is not + // a QObject, so it has no tr() of its own, but the message is still + // user-facing and needs to go through Qt's translation machinery. + outDetail = QCoreApplication::translate( + "PluginLoader", + "“%1” was built for plugin ABI %2; this MeshMC " + "supports %3–%4. It needs to be updated by its " + "author.") + .arg(label) + .arg(builtForAbi) + .arg(abiMin) + .arg(abiMax); + + return builtForAbi < abiMin ? PluginDisableReason::AbiTooOld + : PluginDisableReason::AbiTooNew; +} + void PluginLoader::unloadModule(PluginMetadata& meta) { if (!meta.libraryHandle) diff --git a/launcher/plugin/PluginLoader.h b/launcher/plugin/PluginLoader.h index 9f41c75d..03cb9567 100644 --- a/launcher/plugin/PluginLoader.h +++ b/launcher/plugin/PluginLoader.h @@ -25,6 +25,8 @@ #include #include +#include + /* * PluginLoader — scans known directories for .mmco module files and * performs the low-level dlopen / symbol resolution. @@ -74,6 +76,26 @@ class PluginLoader */ static void unloadModule(PluginMetadata& meta); + /* + * Build the PluginDisableReason and human-readable, translated detail + * for a module whose declared abi_version falls outside + * [abiMin, abiMax]. Only called by loadModule() once it has already + * decided the range check failed, but kept as its own static + * function -- and public -- so the reason/message formatting can be + * unit-tested without dlopen()'ing a real .mmco file: the wording + * depends only on these plain values, never on anything read from + * the module beyond the name loadModule() already captured. + * + * `moduleName` is the module's declared name, or empty if + * mmco_module_info::name was null/blank; `fallbackLabel` (typically + * the .mmco file path) is used in the message in that case so the + * user still gets something to identify the module by. + */ + static PluginDisableReason + classifyAbiMismatch(const QString& moduleName, const QString& fallbackLabel, + uint32_t builtForAbi, uint32_t abiMin, uint32_t abiMax, + QString& outDetail); + /* * Return the ordered list of directories that will be scanned. */ diff --git a/launcher/plugin/PluginLoader_test.cpp b/launcher/plugin/PluginLoader_test.cpp new file mode 100644 index 00000000..f576ca70 --- /dev/null +++ b/launcher/plugin/PluginLoader_test.cpp @@ -0,0 +1,93 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include + +#include "plugin/PluginLoader.h" +#include "plugin/PluginMetadata.h" + +/* + * PluginLoader::loadModule() dlopen()s a real shared-library file and reads + * its mmco_module_info symbol -- there is no way to drive it from a unit + * test without a genuine compiled .mmco fixture, and building one is a + * CMake/toolchain concern that does not belong in a QtTest binary (no such + * fixture exists anywhere in this tree today). + * + * What loadModule() delegates to for an ABI-mismatched module -- + * PluginLoader::classifyAbiMismatch() -- takes only plain values (the name + * already captured from the module, the file path, and three ABI numbers) + * and is exercised directly here instead. It is the piece the design review + * cared about: given a name, does the rejected module keep its identity and + * get a reason/message that says why. + */ +class PluginLoaderTest : public QObject +{ + Q_OBJECT + + private slots: + /* A module built for an ABI below the host's floor is AbiTooOld, and + * its name, the ABI it was built for, and the host's supported range + * all end up in the message so the user (or a bug report) has + * everything needed without digging through logs. */ + void test_tooOldCarriesNameAndRange() + { + QString detail; + const PluginDisableReason reason = PluginLoader::classifyAbiMismatch( + QStringLiteral("OldPlugin"), QStringLiteral("/plugins/OldPlugin.mmco"), + /*builtForAbi=*/1, /*abiMin=*/5, /*abiMax=*/5, detail); + + QCOMPARE(reason, PluginDisableReason::AbiTooOld); + QVERIFY(detail.contains(QStringLiteral("OldPlugin"))); + QVERIFY(detail.contains(QStringLiteral("1"))); + QVERIFY(detail.contains(QStringLiteral("5"))); + } + + /* Symmetric case: built for an ABI above what this launcher knows + * (a downgrade, or a module built for a future release) comes back + * as AbiTooNew rather than being lumped in with "too old". */ + void test_tooNewIsDistinguishedFromTooOld() + { + QString detail; + const PluginDisableReason reason = PluginLoader::classifyAbiMismatch( + QStringLiteral("FuturePlugin"), + QStringLiteral("/plugins/FuturePlugin.mmco"), + /*builtForAbi=*/9, /*abiMin=*/5, /*abiMax=*/5, detail); + + QCOMPARE(reason, PluginDisableReason::AbiTooNew); + QVERIFY(detail.contains(QStringLiteral("FuturePlugin"))); + QVERIFY(detail.contains(QStringLiteral("9"))); + } + + /* mmco_module_info::name may be null/blank; loadModule() then passes + * an empty QString as moduleName. The message must still identify + * the module somehow, so it falls back to the file path. */ + void test_emptyNameFallsBackToPath() + { + QString detail; + PluginLoader::classifyAbiMismatch(QString(), + QStringLiteral("/plugins/Nameless.mmco"), + 1, 5, 5, detail); + + QVERIFY(detail.contains(QStringLiteral("/plugins/Nameless.mmco"))); + } +}; + +QTEST_GUILESS_MAIN(PluginLoaderTest) + +#include "PluginLoader_test.moc" diff --git a/launcher/plugin/PluginManager.cpp b/launcher/plugin/PluginManager.cpp index 2534d2d5..c6c8e867 100644 --- a/launcher/plugin/PluginManager.cpp +++ b/launcher/plugin/PluginManager.cpp @@ -21,6 +21,7 @@ #include "plugin/PluginDependencyResolver.h" #include "plugin/PluginSignature.h" #include "Application.h" +#include "core/UiHost.h" #include "BuildConfig.h" #include "InstanceList.h" #include "BaseInstance.h" @@ -79,11 +80,19 @@ #include #include #include +#include #include #include #include #include #include +#include +#include +#include +#include +#include +#include "plugin/PluginUiRenderer.h" +#include #include #include @@ -300,11 +309,15 @@ void PluginManager::shutdownAll() meta.initialized = false; } - /* Belt-and-braces: if the main window outlives PluginManager (Application - * teardown is awkward), unhook our event filter so it doesn't fire into - * a dead `this`. */ - if (m_closeFilterInstalled && m_filteredMainWindow) { - m_filteredMainWindow->removeEventFilter(this); + /* Belt-and-braces: if the main window (or, under the QML shell, its + * root QWindow) outlives PluginManager (Application teardown is + * awkward), unhook our event filter so it doesn't fire into a dead + * `this`. */ + if (m_closeFilterInstalled) { + if (m_filteredMainWindow) + m_filteredMainWindow->removeEventFilter(this); + if (m_filteredShellWindow) + m_filteredShellWindow->removeEventFilter(this); m_closeFilterInstalled = false; } @@ -371,7 +384,11 @@ bool PluginManager::runBackgroundHooks(uint32_t hook_id, void* payload, /* activeWindow() is null whenever the launcher is not the focused * application, so it answers "where do I parent this dialog", not * "is there a UI at all". The main window answers the second one: - * it does not exist yet during startup and is gone by shutdown. */ + * it does not exist yet during startup and is gone by shutdown. + * ProgressDialog below needs an actual QWidget parent, which the + * QML shell has none of -- resolveMainWindow() stays widget-only + * and simply returns nullptr under the QML shell, same as during + * startup/shutdown, so the dialog falls back to no parent there. */ QWidget* owner = QApplication::activeWindow(); if (!owner) { owner = resolveMainWindow(); @@ -564,29 +581,6 @@ MMCOContext PluginManager::buildContext(PluginMetadata& meta) ctx.ui_file_save_dialog = api_ui_file_save_dialog; ctx.ui_input_dialog = api_ui_input_dialog; ctx.ui_confirm_dialog = api_ui_confirm_dialog; - ctx.ui_register_instance_action = api_ui_register_instance_action; - ctx.ui_register_instance_action_cb = api_ui_register_instance_action_cb; - - // S13 — UI Page Builder - ctx.ui_page_create = api_ui_page_create; - ctx.ui_page_add_to_list = api_ui_page_add_to_list; - ctx.ui_layout_create = api_ui_layout_create; - ctx.ui_layout_add_widget = api_ui_layout_add_widget; - ctx.ui_layout_add_layout = api_ui_layout_add_layout; - ctx.ui_layout_add_spacer = api_ui_layout_add_spacer; - ctx.ui_page_set_layout = api_ui_page_set_layout; - ctx.ui_button_create = api_ui_button_create; - ctx.ui_button_set_enabled = api_ui_button_set_enabled; - ctx.ui_button_set_text = api_ui_button_set_text; - ctx.ui_label_create = api_ui_label_create; - ctx.ui_label_set_text = api_ui_label_set_text; - ctx.ui_tree_create = api_ui_tree_create; - ctx.ui_tree_clear = api_ui_tree_clear; - ctx.ui_tree_add_row = api_ui_tree_add_row; - ctx.ui_tree_selected_row = api_ui_tree_selected_row; - ctx.ui_tree_set_row_data = api_ui_tree_set_row_data; - ctx.ui_tree_get_row_data = api_ui_tree_get_row_data; - ctx.ui_tree_row_count = api_ui_tree_row_count; // S14 — Utility ctx.get_app_version = api_get_app_version; @@ -626,14 +620,6 @@ MMCOContext PluginManager::buildContext(PluginMetadata& meta) ctx.tray_show_message = api_tray_show_message; ctx.tray_set_menu = api_tray_set_menu; ctx.tray_set_activation_cb = api_tray_set_activation_cb; - ctx.tray_menu_create = api_tray_menu_create; - ctx.tray_menu_destroy = api_tray_menu_destroy; - ctx.tray_menu_clear = api_tray_menu_clear; - ctx.tray_menu_add_separator = api_tray_menu_add_separator; - ctx.tray_menu_add_action = api_tray_menu_add_action; - ctx.tray_menu_action_set_enabled = api_tray_menu_action_set_enabled; - ctx.tray_menu_action_set_text = api_tray_menu_action_set_text; - ctx.tray_menu_add_submenu = api_tray_menu_add_submenu; // S20 — Main window helpers ctx.main_window_install_close_filter = api_main_window_install_close_filter; @@ -692,6 +678,14 @@ MMCOContext PluginManager::buildContext(PluginMetadata& meta) // S31 — Subprocess execution ctx.process_run = api_process_run; + // S33 — Declarative UI surfaces (ABI 5) + ctx.ui_surface_create = api_ui_surface_create; + ctx.ui_surface_update = api_ui_surface_update; + ctx.ui_surface_set = api_ui_surface_set; + ctx.ui_surface_set_rows = api_ui_surface_set_rows; + ctx.ui_surface_destroy = api_ui_surface_destroy; + ctx.ui_modal_run = api_ui_modal_run; + return ctx; } @@ -1561,6 +1555,30 @@ void PluginManager::api_ui_show_message(void* mh, int type, const char* title, QString("[%1] %2").arg(meta.name, QString::fromUtf8(title)); QString qmsg = QString::fromUtf8(msg); + /* Under the QML shell there is no widget window to parent a QMessageBox + * to (see the qml-preview-tools audit, plan item 2) -- route through + * the same UiHost a plugin's message()/confirm() would already reach on + * the core side, instead of a plugin popping a widget the QML shell + * must never show. The classic UI is untouched: usingQmlShell() is + * false there, so this falls through to the QMessageBox it always + * used. */ + auto* app = r->manager->m_app; + if (app && app->usingQmlShell()) { + UiHost::Severity severity = UiHost::Severity::Information; + switch (type) { + case 1: + severity = UiHost::Severity::Warning; + break; + case 2: + severity = UiHost::Severity::Critical; + break; + default: + break; + } + app->uiHost()->message(qtitle, qmsg, severity); + return; + } + switch (type) { case 1: QMessageBox::warning(nullptr, qtitle, qmsg); @@ -2139,13 +2157,26 @@ const char* PluginManager::api_ui_file_open_dialog(void* mh, const char* title, const char* filter) { auto* r = rt(mh); - QString result = QFileDialog::getOpenFileName( - QApplication::activeWindow(), - title ? QString::fromUtf8(title) : QString(), QString(), - filter ? QString::fromUtf8(filter) : QString()); - if (result.isEmpty()) + const QString qtitle = title ? QString::fromUtf8(title) : QString(); + const QString qfilter = filter ? QString::fromUtf8(filter) : QString(); + + std::optional result; + auto* app = r->manager->m_app; + if (app && app->usingQmlShell()) { + /* QApplication::activeWindow() is always null under the QML shell + * (see the audit) -- ask through UiHost instead, which shows a + * QtQuick.Dialogs FileDialog rather than this QFileDialog. */ + result = app->uiHost()->pickFile(UiHost::FilePickerMode::Open, qtitle, + QString(), qfilter); + } else { + const QString path = QFileDialog::getOpenFileName( + QApplication::activeWindow(), qtitle, QString(), qfilter); + if (!path.isEmpty()) + result = path; + } + if (!result) return nullptr; - r->tempString = result.toStdString(); + r->tempString = result->toStdString(); return r->tempString.c_str(); } @@ -2154,14 +2185,24 @@ const char* PluginManager::api_ui_file_save_dialog(void* mh, const char* title, const char* filter) { auto* r = rt(mh); - QString result = QFileDialog::getSaveFileName( - QApplication::activeWindow(), - title ? QString::fromUtf8(title) : QString(), - def ? QString::fromUtf8(def) : QString(), - filter ? QString::fromUtf8(filter) : QString()); - if (result.isEmpty()) + const QString qtitle = title ? QString::fromUtf8(title) : QString(); + const QString qdef = def ? QString::fromUtf8(def) : QString(); + const QString qfilter = filter ? QString::fromUtf8(filter) : QString(); + + std::optional result; + auto* app = r->manager->m_app; + if (app && app->usingQmlShell()) { + result = app->uiHost()->pickFile(UiHost::FilePickerMode::Save, qtitle, + qdef, qfilter); + } else { + const QString path = QFileDialog::getSaveFileName( + QApplication::activeWindow(), qtitle, qdef, qfilter); + if (!path.isEmpty()) + result = path; + } + if (!result) return nullptr; - r->tempString = result.toStdString(); + r->tempString = result->toStdString(); return r->tempString.c_str(); } @@ -2170,11 +2211,22 @@ const char* PluginManager::api_ui_input_dialog(void* mh, const char* title, const char* def) { auto* r = rt(mh); + const QString qtitle = title ? QString::fromUtf8(title) : QString(); + const QString qprompt = prompt ? QString::fromUtf8(prompt) : QString(); + const QString qdef = def ? QString::fromUtf8(def) : QString(); + + auto* app = r->manager->m_app; + if (app && app->usingQmlShell()) { + const auto answer = app->uiHost()->askText(qtitle, qprompt, qdef); + if (!answer) + return nullptr; + r->tempString = answer->toStdString(); + return r->tempString.c_str(); + } + bool ok = false; - QString result = QInputDialog::getText( - nullptr, title ? QString::fromUtf8(title) : QString(), - prompt ? QString::fromUtf8(prompt) : QString(), QLineEdit::Normal, - def ? QString::fromUtf8(def) : QString(), &ok); + QString result = QInputDialog::getText(nullptr, qtitle, qprompt, + QLineEdit::Normal, qdef, &ok); if (!ok) return nullptr; r->tempString = result.toStdString(); @@ -2184,82 +2236,141 @@ const char* PluginManager::api_ui_input_dialog(void* mh, const char* title, int PluginManager::api_ui_confirm_dialog(void* mh, const char* title, const char* msg) { - (void)mh; - auto ret = QMessageBox::question( - nullptr, title ? QString::fromUtf8(title) : QString(), - msg ? QString::fromUtf8(msg) : QString(), - QMessageBox::Yes | QMessageBox::No, QMessageBox::No); + auto* r = rt(mh); + const QString qtitle = title ? QString::fromUtf8(title) : QString(); + const QString qmsg = msg ? QString::fromUtf8(msg) : QString(); + + auto* app = r->manager->m_app; + if (app && app->usingQmlShell()) { + return app->uiHost()->confirm(qtitle, qmsg, UiHost::Severity::Question) + ? 1 + : 0; + } + + auto ret = QMessageBox::question(nullptr, qtitle, qmsg, + QMessageBox::Yes | QMessageBox::No, + QMessageBox::No); return ret == QMessageBox::Yes ? 1 : 0; } /* - * ─── Deprecated: instance sidebar injection ─────────────────────────── - * - * Both entry points below stay in the table so existing .mmco modules - * keep loading and keep the rest of their features, but they no longer - * register anything. + * ─── ABI 5 — Declarative UI surfaces ─────────────────────────────── * - * The instance sidebar was cut back to a fixed set of instance-wide - * commands -- launch, edit, group, folder, export, copy, delete -- and - * an open-ended list of plugin buttons is the surest way for it to - * drift straight back out again. What a plugin wants to show for an - * instance belongs in the instance window, where - * ui_register_instance_page() already puts it alongside Mods, Worlds - * and the rest, with room to breathe instead of one line in a column. + * Replaces the deleted S13 imperative widget builder (ui_page_create + * through ui_tree_row_count) and the deleted ui_register_instance_action + * / ui_register_instance_action_cb no-ops (both are gone from the ABI + * entirely as of ABI 5 -- MMCO_ABI_VERSION_MIN jumping to 5 means any + * module that still called them no longer links, so there is nothing + * to keep as a compatibility no-op the way there was for ABI 4). * - * They report 0, the same "not registered" a caller would get if the - * launcher had run out of memory. Claiming success would leave a plugin - * waiting on a button that is never going to appear. + * A plugin describes a small widget tree as an "mmco-ui/1" JSON + * document (see PluginUiRenderer.h) and the host renders and owns the + * real QWidget tree; no QWidget* is ever handed back to a plugin. Each + * SurfaceRecord's `doc` is the canonical, always-current document -- + * ui_surface_update/_set/_set_rows mutate it whether or not a view is + * currently on screen, and additionally patch the live widget when one + * is mounted (see PluginManager.h's SurfaceRecord for the full + * rationale). The three anchors are realised at three different call + * sites, all reading from `m_surfaces` fresh every time: + * - MMCO_UI_ANCHOR_INSTANCE_PAGE -> createInstancePages(), called + * from InstancePageProvider::getPages(). + * - MMCO_UI_ANCHOR_GLOBAL_SETTINGS -> createGlobalSettingsPluginsPage(), + * called from Application.cpp's PluginAugmentedPageProvider. + * - MMCO_UI_ANCHOR_INSTANCE_SETTINGS -> buildPluginsSectionWidget(), + * called from connectAppSignals()'s INSTANCE_SETTINGS_PAGE_CREATED + * bridge below. */ -int PluginManager::api_ui_register_instance_action(void* mh, const char* text, - const char* tooltip, - const char* icon_name, - const char* page_id) -{ - (void)mh; - (void)tooltip; - (void)icon_name; - qWarning() << "ui_register_instance_action() is deprecated and does " - "nothing; ignoring instance action" - << (text ? text : "(unnamed)") << "for page" - << (page_id ? page_id : "(none)") - << "- register an instance page instead."; - return 0; -} - -int PluginManager::api_ui_register_instance_action_cb( - void* mh, const char* text, const char* tooltip, const char* icon_name, - void (*cb)(void* ud), void* ud) -{ - (void)mh; - (void)tooltip; - (void)icon_name; - (void)cb; - (void)ud; - qWarning() << "ui_register_instance_action_cb() is deprecated and does " - "nothing; ignoring instance action" - << (text ? text : "(unnamed)") - << "- register an instance page instead."; - return 0; -} - #include "ui/pages/BasePage.h" namespace { + /* Turn a SurfaceRecord's current document into JSON text for + * PluginUiRenderer::build(). */ + QString surfaceDocToText(const QJsonObject& doc) + { + return QString::fromUtf8(QJsonDocument(doc).toJson(QJsonDocument::Compact)); + } - class PluginPage : public QWidget, public BasePage + /* Walks `node`'s subtree for the id `nodeId`; when found, merges + * `patch` into its "props" object (creating one if absent) and + * returns true. Shared by ui_surface_set (an arbitrary props + * patch) and ui_surface_set_rows (a `{"rows": [...]}` patch is + * just another props patch), so both ways of mutating the + * canonical document go through the same tree walk. */ + bool patchNodeProps(QJsonObject& node, const QString& nodeId, + const QJsonObject& patch) + { + if (node.value(QStringLiteral("id")).toString() == nodeId) { + QJsonObject props = node.value(QStringLiteral("props")).toObject(); + for (auto it = patch.constBegin(); it != patch.constEnd(); ++it) + props.insert(it.key(), it.value()); + node[QStringLiteral("props")] = props; + return true; + } + if (node.contains(QStringLiteral("children"))) { + QJsonArray children = node.value(QStringLiteral("children")).toArray(); + for (int i = 0; i < children.size(); ++i) { + QJsonObject child = children.at(i).toObject(); + if (patchNodeProps(child, nodeId, patch)) { + children[i] = child; + node[QStringLiteral("children")] = children; + return true; + } + } + } + return false; + } + + bool patchDocumentNodeProps(QJsonObject& doc, const QString& nodeId, + const QJsonObject& patch) + { + QJsonObject root = doc.value(QStringLiteral("root")).toObject(); + if (root.isEmpty()) + return false; + if (!patchNodeProps(root, nodeId, patch)) + return false; + doc[QStringLiteral("root")] = root; + return true; + } + + /* Ties a PluginUiRenderer::RenderedSurface's lifetime to whatever + * QWidget it ends up parented under -- used when several surfaces + * are stacked into one combined section widget (GLOBAL_SETTINGS / + * INSTANCE_SETTINGS), where the natural container is a plain + * QGroupBox we don't otherwise subclass. */ + class RendererOwner : public QObject { - Q_OBJECT public: - PluginPage(const QString& pageId, const QString& displayName, - const QString& iconName, QWidget* parent = nullptr) - : QWidget(parent), m_id(pageId), m_displayName(displayName), - m_iconName(iconName) + RendererOwner(std::unique_ptr renderer, + QObject* parent) + : QObject(parent), m_renderer(std::move(renderer)) { } + private: + std::unique_ptr m_renderer; + }; + + /* One MMCO_UI_ANCHOR_INSTANCE_PAGE surface, wrapped as its own + * instance-window page -- the declarative replacement for + * GitVersioningPage-style ad-hoc BasePage subclasses. Owns the + * RenderedSurface directly since it never shares its content with + * another page. */ + class PluginSurfacePage : public QWidget, public BasePage + { + public: + PluginSurfacePage(QString id, QString displayName, QString iconName, + QWidget* content, + std::unique_ptr renderer) + : m_id(std::move(id)), m_displayName(std::move(displayName)), + m_iconName(std::move(iconName)), m_renderer(std::move(renderer)) + { + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->addWidget(content); + } + QString id() const override { return m_id; @@ -2270,11 +2381,10 @@ namespace } QIcon icon() const override { - // Accept either a Qt resource path (":/...") or a themed - // icon name. Resource paths come from ui_plugin_icon(). if (m_iconName.startsWith(QLatin1Char(':'))) return QIcon(m_iconName); - return QIcon::fromTheme(m_iconName); + return QIcon::fromTheme(m_iconName.isEmpty() ? QStringLiteral("plugin") + : m_iconName); } bool shouldDisplay() const override { @@ -2285,265 +2395,408 @@ namespace QString m_id; QString m_displayName; QString m_iconName; + std::unique_ptr m_renderer; + }; + + /* The single host-built page every MMCO_UI_ANCHOR_GLOBAL_SETTINGS + * surface is stacked into -- see createGlobalSettingsPluginsPage() + * for why one shared page beats one page per plugin. */ + class PluginsGroupPage : public QWidget, public BasePage + { + public: + explicit PluginsGroupPage(QWidget* content) + { + auto* scroll = new QScrollArea(this); + scroll->setWidgetResizable(true); + scroll->setFrameShape(QFrame::NoFrame); + scroll->setWidget(content); + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->addWidget(scroll); + } + QString id() const override + { + return QStringLiteral("plugins"); + } + QString displayName() const override + { + return PluginManager::tr("Plugins"); + } + QIcon icon() const override + { + return QIcon::fromTheme(QStringLiteral("plugin")); + } + bool shouldDisplay() const override + { + return true; + } }; } // anonymous namespace -void* PluginManager::api_ui_page_create(void* mh, const char* id, - const char* name, const char* iconName) +PluginManager::SurfaceRecord* PluginManager::findSurface(void* module_handle, + void* surface) { - (void)mh; - if (!id || !name) - return nullptr; - auto* page = new PluginPage(QString::fromUtf8(id), QString::fromUtf8(name), - iconName ? QString::fromUtf8(iconName) - : QStringLiteral("plugin")); - return static_cast(page); + for (auto& rec : m_surfaces) { + if (rec.get() == surface && rec->module_handle == module_handle) + return rec.get(); + } + return nullptr; } -int PluginManager::api_ui_page_add_to_list(void* mh, void* page, void* list) +PluginUiRenderer::EventSink PluginManager::makeSurfaceSink(SurfaceRecord* rec) { - (void)mh; - if (!page || !list) - return -1; - auto* pageWidget = static_cast(page); - auto* pageBase = dynamic_cast(pageWidget); - if (!pageBase) - return -1; - auto* pages = static_cast*>(list); - pages->append(pageBase); - return 0; + MMCOUiEventCallback cb = rec->cb; + void* userData = rec->userData; + QString surfaceId = rec->surfaceId; + return [cb, userData, surfaceId](const QString& nodeId, const QString& event, + const QString& valueJson) { + if (!cb) + return; + const QByteArray sid = surfaceId.toUtf8(); + const QByteArray nid = nodeId.toUtf8(); + const QByteArray ev = event.toUtf8(); + const QByteArray val = valueJson.toUtf8(); + cb(userData, sid.constData(), nid.constData(), ev.constData(), + val.constData()); + }; } -void* PluginManager::api_ui_layout_create(void* mh, void* parent, int type) +void* PluginManager::api_ui_surface_create(void* mh, int anchor, + const char* anchor_context, + const char* title, const char* icon_name, + const char* json_doc, + MMCOUiEventCallback cb, void* user_data) { - (void)mh; - QWidget* pw = parent ? static_cast(parent) : nullptr; - QBoxLayout* layout; - if (type == 1) - layout = new QHBoxLayout(); - else - layout = new QVBoxLayout(); - // Don't set on parent yet — let page_set_layout do that - (void)pw; - return layout; -} + auto* r = rt(mh); + if (!r) + return nullptr; + if (anchor != MMCO_UI_ANCHOR_GLOBAL_SETTINGS && + anchor != MMCO_UI_ANCHOR_INSTANCE_PAGE && + anchor != MMCO_UI_ANCHOR_INSTANCE_SETTINGS) + return nullptr; -int PluginManager::api_ui_layout_add_widget(void* mh, void* layout, - void* widget) + QJsonParseError err{}; + const QJsonDocument jd = + QJsonDocument::fromJson(QByteArray(json_doc ? json_doc : ""), &err); + if (err.error != QJsonParseError::NoError || !jd.isObject()) { + qWarning().noquote() + << "[Plugin:" << r->manager->m_modules[r->moduleIndex].name + << "] ui_surface_create: invalid JSON document:" << err.errorString(); + return nullptr; + } + + auto rec = std::make_unique(); + rec->module_handle = mh; + rec->surfaceId = QStringLiteral("sf-%1").arg(++r->manager->m_nextSurfaceSeq); + rec->anchor = anchor; + rec->anchorContext = + anchor_context ? QString::fromUtf8(anchor_context) : QString(); + rec->title = title ? QString::fromUtf8(title) : QString(); + rec->iconName = icon_name ? QString::fromUtf8(icon_name) : QString(); + rec->doc = jd.object(); + rec->cb = cb; + rec->userData = user_data; + + auto* handle = rec.get(); + qCDebug(pluginsLog).noquote() + << "[Plugin:" << r->manager->m_modules[r->moduleIndex].name + << "] ui_surface_create: anchor" << anchor << "context" + << (anchor_context ? QString::fromUtf8(anchor_context) : QString()) + << "->" << handle->surfaceId; + r->manager->m_surfaces.push_back(std::move(rec)); + emit r->manager->surfacesChanged(); + return handle; +} + +int PluginManager::api_ui_surface_update(void* mh, void* surface, + const char* json_doc) { - (void)mh; - if (!layout || !widget) + auto* r = rt(mh); + if (!r) + return -1; + auto* rec = r->manager->findSurface(mh, surface); + if (!rec) return -1; - auto* l = static_cast(layout); - l->addWidget(static_cast(widget)); - return 0; -} -int PluginManager::api_ui_layout_add_layout(void* mh, void* parent, void* child) -{ - (void)mh; - if (!parent || !child) + QJsonParseError err{}; + const QJsonDocument jd = + QJsonDocument::fromJson(QByteArray(json_doc ? json_doc : ""), &err); + if (err.error != QJsonParseError::NoError || !jd.isObject()) return -1; - auto* p = static_cast(parent); - p->addLayout(static_cast(child)); + + rec->doc = jd.object(); + if (rec->mountedRoot && rec->mountedRenderer) + rec->mountedRenderer->setDocument(rec->doc); + emit r->manager->surfacesChanged(); return 0; } -int PluginManager::api_ui_layout_add_spacer(void* mh, void* layout, - int horizontal) +int PluginManager::api_ui_surface_set(void* mh, void* surface, const char* node_id, + const char* json_props) { - (void)mh; - if (!layout) + auto* r = rt(mh); + if (!r || !node_id) + return -1; + auto* rec = r->manager->findSurface(mh, surface); + if (!rec) return -1; - auto* l = static_cast(layout); - if (horizontal) - l->addItem(new QSpacerItem(0, 0, QSizePolicy::Expanding, - QSizePolicy::Minimum)); - else - l->addItem(new QSpacerItem(0, 0, QSizePolicy::Minimum, - QSizePolicy::Expanding)); - return 0; -} -int PluginManager::api_ui_page_set_layout(void* mh, void* page, void* layout) -{ - (void)mh; - if (!page || !layout) + QJsonParseError err{}; + const QJsonDocument jd = + QJsonDocument::fromJson(QByteArray(json_props ? json_props : "{}"), &err); + if (err.error != QJsonParseError::NoError || !jd.isObject()) return -1; - auto* w = static_cast(page); - w->setLayout(static_cast(layout)); - return 0; -} -void* PluginManager::api_ui_button_create(void* mh, void* parent, - const char* text, - const char* iconName, - MMCOButtonCallback cb, void* ud) -{ - (void)mh; - auto* btn = new QPushButton(text ? QString::fromUtf8(text) : QString()); - if (iconName && iconName[0] != '\0') { - const QString iname = QString::fromUtf8(iconName); - btn->setIcon(iname.startsWith(QLatin1Char(':')) - ? QIcon(iname) - : QIcon::fromTheme(iname)); - } - if (parent) - btn->setParent(static_cast(parent)); - if (cb) { - QObject::connect(btn, &QPushButton::clicked, [cb, ud]() { cb(ud); }); - } - return btn; + const QString nodeId = QString::fromUtf8(node_id); + const QJsonObject props = jd.object(); + const bool foundInDoc = patchDocumentNodeProps(rec->doc, nodeId, props); + if (rec->mountedRoot && rec->mountedRenderer) + rec->mountedRenderer->setNodeProps(nodeId, props); + /* The widget renderer already reflects this patch live via + * mountedRenderer above; surfacesChanged() is the same notification + * for anything reading `doc` instead (a future QML surface model — see + * PluginSurfaceModel), which otherwise never learns a mounted-or-not + * toggle/list/etc. just changed value. Only when the document actually + * moved, same condition ui_surface_update/_create/_destroy already use. */ + if (foundInDoc) + emit r->manager->surfacesChanged(); + return foundInDoc ? 0 : -1; } -int PluginManager::api_ui_button_set_enabled(void* mh, void* btn, int enabled) +int PluginManager::api_ui_surface_set_rows(void* mh, void* surface, + const char* node_id, + const char* json_rows) { - (void)mh; - if (!btn) + auto* r = rt(mh); + if (!r || !node_id) + return -1; + auto* rec = r->manager->findSurface(mh, surface); + if (!rec) return -1; - static_cast(btn)->setEnabled(enabled != 0); - return 0; -} -int PluginManager::api_ui_button_set_text(void* mh, void* btn, const char* text) -{ - (void)mh; - if (!btn) + QJsonParseError err{}; + const QJsonDocument jd = + QJsonDocument::fromJson(QByteArray(json_rows ? json_rows : "[]"), &err); + if (err.error != QJsonParseError::NoError || !jd.isArray()) return -1; - static_cast(btn)->setText(text ? QString::fromUtf8(text) - : QString()); - return 0; -} -void* PluginManager::api_ui_label_create(void* mh, void* parent, - const char* text) -{ - (void)mh; - auto* lbl = new QLabel(text ? QString::fromUtf8(text) : QString()); - if (parent) - lbl->setParent(static_cast(parent)); - return lbl; + const QString nodeId = QString::fromUtf8(node_id); + const QJsonArray rows = jd.array(); + QJsonObject rowsPatch; + rowsPatch[QStringLiteral("rows")] = rows; + const bool foundInDoc = patchDocumentNodeProps(rec->doc, nodeId, rowsPatch); + if (rec->mountedRoot && rec->mountedRenderer) + rec->mountedRenderer->setRows(nodeId, rows); + /* See api_ui_surface_set() above — same reasoning, same condition. */ + if (foundInDoc) + emit r->manager->surfacesChanged(); + return foundInDoc ? 0 : -1; } -int PluginManager::api_ui_label_set_text(void* mh, void* label, - const char* text) +int PluginManager::api_ui_surface_destroy(void* mh, void* surface) { - (void)mh; - if (!label) + auto* r = rt(mh); + if (!r) return -1; - static_cast(label)->setText(text ? QString::fromUtf8(text) - : QString()); - return 0; + auto& vec = r->manager->m_surfaces; + for (size_t i = 0; i < vec.size(); ++i) { + if (vec[i].get() != surface || vec[i]->module_handle != mh) + continue; + if (vec[i]->mountedRoot && !r->manager->m_shutdownDone) + vec[i]->mountedRoot->deleteLater(); + vec.erase(vec.begin() + static_cast(i)); + emit r->manager->surfacesChanged(); + return 0; + } + return -1; } -void* PluginManager::api_ui_tree_create(void* mh, void* parent, - const char** cols, int ncols, - MMCOTreeSelectionCallback cb, void* ud) +int PluginManager::api_ui_modal_run(void* mh, const char* title, + const char* json_doc, char* out_result_json, + int out_buf_size) { - (void)mh; - auto* tree = new QTreeWidget(); - tree->setRootIsDecorated(false); - tree->setSortingEnabled(true); - tree->setAlternatingRowColors(true); - tree->setSelectionMode(QAbstractItemView::SingleSelection); - - if (parent) - tree->setParent(static_cast(parent)); - - QStringList headers; - for (int i = 0; i < ncols; ++i) - headers << (cols[i] ? QString::fromUtf8(cols[i]) : QString()); - tree->setHeaderLabels(headers); - - // First column stretches - if (ncols > 0) { - tree->header()->setStretchLastSection(false); - tree->header()->setSectionResizeMode(0, QHeaderView::Stretch); - for (int i = 1; i < ncols; ++i) - tree->header()->setSectionResizeMode(i, - QHeaderView::ResizeToContents); + if (!json_doc) + return -1; + + /* No QML-native renderer for a one-off "mmco-ui/1" modal document yet -- + * PluginSurfaceModel only serves the anchored, persistent surfaces + * (Settings/instance-page plugin sections), not this ad-hoc prompt + * shape (see the qml-preview-tools audit, plan item 3). Rather than + * fall back to the QDialog below -- which the QML shell must never + * show -- refuse cleanly so a plugin author sees a real "unsupported" + * result instead of a widget window appearing out of nowhere. */ + if (auto* r = rt(mh); r && r->manager->m_app && + r->manager->m_app->usingQmlShell()) { + return -1; } - if (cb) { - QObject::connect( - tree, &QTreeWidget::itemSelectionChanged, [tree, cb, ud]() { - auto items = tree->selectedItems(); - int row = items.isEmpty() - ? -1 - : tree->indexOfTopLevelItem(items.first()); - cb(ud, row); - }); + QJsonParseError err{}; + const QJsonDocument jd = QJsonDocument::fromJson(QByteArray(json_doc), &err); + if (err.error != QJsonParseError::NoError || !jd.isObject()) + return -1; + + QDialog dlg(QApplication::activeWindow()); + dlg.setWindowTitle(title ? QString::fromUtf8(title) : QString()); + auto* layout = new QVBoxLayout(&dlg); + + /* Any `click` event (button, or a `link` if a plugin puts one in a + * modal doc) closes the dialog with that node's id -- ui_modal_run + * is meant for small button-row prompts, not full pages. */ + QString clickedId; + auto renderer = PluginUiRenderer::build( + surfaceDocToText(jd.object()), + [&clickedId, &dlg](const QString& nodeId, const QString& event, + const QString& /*valueJson*/) { + if (event == QLatin1String("click")) { + clickedId = nodeId; + dlg.accept(); + } + }); + layout->addWidget(renderer->rootWidget()); + + const int code = dlg.exec(); + if (code != QDialog::Accepted || clickedId.isEmpty()) + return -1; + + QJsonObject result; + result[QStringLiteral("button")] = clickedId; + result[QStringLiteral("fields")] = renderer->collectValues(); + const QByteArray bytes = QJsonDocument(result).toJson(QJsonDocument::Compact); + + if (out_result_json && out_buf_size > 0) { + const int n = qMin(static_cast(bytes.size()), out_buf_size - 1); + memcpy(out_result_json, bytes.constData(), static_cast(n)); + out_result_json[n] = '\0'; } - return tree; + return 0; } -int PluginManager::api_ui_tree_clear(void* mh, void* tree) +QList PluginManager::createInstancePages(const QString& instanceId) { - (void)mh; - if (!tree) - return -1; - static_cast(tree)->clear(); - return 0; + QList pages; + for (auto& rec : m_surfaces) { + if (rec->anchor != MMCO_UI_ANCHOR_INSTANCE_PAGE || + rec->anchorContext != instanceId) + continue; + SurfaceRecord* recPtr = rec.get(); + auto renderer = PluginUiRenderer::build(surfaceDocToText(recPtr->doc), + makeSurfaceSink(recPtr)); + QWidget* root = renderer->rootWidget(); + recPtr->mountedRoot = root; + recPtr->mountedRenderer = renderer.get(); + pages.append(new PluginSurfacePage(recPtr->surfaceId, recPtr->title, + recPtr->iconName, root, + std::move(renderer))); + } + return pages; } -int PluginManager::api_ui_tree_add_row(void* mh, void* tree, const char** vals, - int ncols) +QWidget* PluginManager::buildPluginsSectionWidget(int anchor, + const QString& anchorContext) { - (void)mh; - if (!tree) - return -1; - auto* tw = static_cast(tree); - auto* item = new QTreeWidgetItem(tw); - for (int i = 0; i < ncols; ++i) - item->setText(i, vals[i] ? QString::fromUtf8(vals[i]) : QString()); - return tw->indexOfTopLevelItem(item); + QWidget* container = nullptr; + QVBoxLayout* layout = nullptr; + for (auto& rec : m_surfaces) { + if (rec->anchor != anchor || rec->anchorContext != anchorContext) + continue; + if (!container) { + container = new QWidget(); + layout = new QVBoxLayout(container); + layout->setContentsMargins(0, 0, 0, 0); + } + SurfaceRecord* recPtr = rec.get(); + auto renderer = PluginUiRenderer::build(surfaceDocToText(recPtr->doc), + makeSurfaceSink(recPtr)); + auto* group = new QGroupBox(recPtr->title); + auto* groupLayout = new QVBoxLayout(group); + groupLayout->addWidget(renderer->rootWidget()); + recPtr->mountedRoot = renderer->rootWidget(); + recPtr->mountedRenderer = renderer.get(); + /* Ties the RenderedSurface's lifetime to `group` -- released + * automatically when the enclosing page/dialog tears `group` + * down (see RendererOwner above). */ + new RendererOwner(std::move(renderer), group); + layout->addWidget(group); + } + if (layout) + layout->addStretch(1); + return container; } -int PluginManager::api_ui_tree_selected_row(void* mh, void* tree) +BasePage* PluginManager::createGlobalSettingsPluginsPage() { - (void)mh; - if (!tree) - return -1; - auto* tw = static_cast(tree); - auto items = tw->selectedItems(); - if (items.isEmpty()) - return -1; - return tw->indexOfTopLevelItem(items.first()); + /* + * Design choice (per the migration spec's request to explain it): + * one host-built "Plugins" page stacking every GLOBAL_SETTINGS + * surface as a titled section, rather than one settings-dialog + * page per plugin. A page per plugin would need a stable per-page + * id/title/icon contract long before most plugins have any more + * than a single checkbox to show (see the S18/S19/NVIDIAPrime/ + * LinuxPerf migrations -- all one section each), so it would mean + * a Settings dialog sidebar cluttered with one-line pages. Stacking + * sections in one page is exactly what the allWidgets()/findChild + * pattern it replaces already produced visually (one GroupBox per + * plugin inside MeshMCPage/MinecraftPage) -- same look, without the + * plugin ever reaching into host internals to get there. + */ + QWidget* content = + buildPluginsSectionWidget(MMCO_UI_ANCHOR_GLOBAL_SETTINGS, QString()); + if (!content) + return nullptr; + return new PluginsGroupPage(content); } -int PluginManager::api_ui_tree_set_row_data(void* mh, void* tree, int row, - int64_t data) +void PluginManager::releaseSurfacesForModule(void* module_handle) { - (void)mh; - if (!tree) - return -1; - auto* tw = static_cast(tree); - auto* item = tw->topLevelItem(row); - if (!item) - return -1; - item->setData(0, Qt::UserRole, QVariant::fromValue(data)); - return 0; + for (int i = static_cast(m_surfaces.size()) - 1; i >= 0; --i) { + auto& rec = m_surfaces[static_cast(i)]; + if (rec->module_handle != module_handle) + continue; + if (rec->mountedRoot && !m_shutdownDone) + rec->mountedRoot->deleteLater(); + m_surfaces.erase(m_surfaces.begin() + i); + } } -int64_t PluginManager::api_ui_tree_get_row_data(void* mh, void* tree, int row) +QList +PluginManager::surfaces(int anchor, const QString& anchorContext) const { - (void)mh; - if (!tree) - return 0; - auto* tw = static_cast(tree); - auto* item = tw->topLevelItem(row); - if (!item) - return 0; - return item->data(0, Qt::UserRole).toLongLong(); + QList out; + for (auto& rec : m_surfaces) { + if (anchor >= 0 && rec->anchor != anchor) + continue; + if (!anchorContext.isNull() && rec->anchorContext != anchorContext) + continue; + SurfaceInfo info; + info.handle = rec.get(); + info.surfaceId = rec->surfaceId; + info.anchor = rec->anchor; + info.anchorContext = rec->anchorContext; + info.title = rec->title; + info.iconName = rec->iconName; + info.document = surfaceDocToText(rec->doc); + out.append(info); + } + return out; } -int PluginManager::api_ui_tree_row_count(void* mh, void* tree) +void PluginManager::deliverUiEvent(const QString& surfaceId, const QString& nodeId, + const QString& event, const QString& valueJson) { - (void)mh; - if (!tree) - return 0; - return static_cast(tree)->topLevelItemCount(); + for (auto& rec : m_surfaces) { + if (rec->surfaceId != surfaceId) + continue; + if (rec->cb) { + const QByteArray sid = surfaceId.toUtf8(); + const QByteArray nid = nodeId.toUtf8(); + const QByteArray ev = event.toUtf8(); + const QByteArray val = valueJson.toUtf8(); + rec->cb(rec->userData, sid.constData(), nid.constData(), + ev.constData(), val.constData()); + } + return; + } } /* ── S15 — Launch Modifiers ───────────────────────────────────────── */ @@ -2879,22 +3132,50 @@ QWidget* PluginManager::resolveMainWindow() return nullptr; } +QWindow* PluginManager::resolveShellWindow() +{ + if (m_filteredShellWindow) + return m_filteredShellWindow.data(); + if (!m_app) + return nullptr; + + /* Application only ever has a QML shell window when the widget + * MainWindow does not exist (see useQmlShell() in Application.cpp), + * so callers that try resolveMainWindow() first never get both. */ + QWindow* window = m_app->qmlShellWindow(); + if (window) + m_filteredShellWindow = window; + return window; +} + void PluginManager::ensureCloseFilterInstalled() { if (m_closeFilterInstalled) return; - QWidget* mw = resolveMainWindow(); - if (!mw) + if (QWidget* mw = resolveMainWindow()) { + mw->installEventFilter(this); + m_closeFilterInstalled = true; return; - mw->installEventFilter(this); - m_closeFilterInstalled = true; + } + if (QWindow* window = resolveShellWindow()) { + window->installEventFilter(this); + m_closeFilterInstalled = true; + } } bool PluginManager::eventFilter(QObject* watched, QEvent* event) { - /* Only filter close events on the main window. */ - if (event && event->type() == QEvent::Close && watched && - watched == m_filteredMainWindow.data() && !m_closeFilters.isEmpty()) { + /* Only filter close events on the main window -- the widget + * MainWindow, or (generalised for the QML shell, which has no such + * widget) its top-level QQuickWindow. QmlShell installs its own + * event filter on the same window to notice a real, un-vetoed close + * (see QmlShell::eventFilter()); being installed later, this filter + * runs first and can stop a vetoed close right here, the same way a + * vetoed close never reaches MainWindow::closeEvent(). */ + const bool isMainWindow = watched && watched == m_filteredMainWindow.data(); + const bool isShellWindow = watched && watched == m_filteredShellWindow.data(); + if (event && event->type() == QEvent::Close && + (isMainWindow || isShellWindow) && !m_closeFilters.isEmpty()) { bool swallow = false; /* Iterate over a copy: callbacks may install/remove filters. */ const auto filters = m_closeFilters; @@ -2908,9 +3189,14 @@ bool PluginManager::eventFilter(QObject* watched, QEvent* event) if (swallow) { auto* ce = static_cast(event); ce->ignore(); - /* Hide rather than close — mirrors what tray-aware apps do. */ - if (auto* mw = qobject_cast(watched)) - mw->hide(); + /* Hide rather than close — mirrors what tray-aware apps do, + * under either UI. */ + if (isMainWindow) { + if (auto* mw = qobject_cast(watched)) + mw->hide(); + } else if (auto* window = qobject_cast(watched)) { + window->hide(); + } return true; } } @@ -2933,11 +3219,6 @@ void PluginManager::releaseTrayResourcesForModule(void* module_handle) * dlclose() would corrupt it (see the long comment in * shutdownAll()) — the OS reclaims everything at process exit * anyway. - * - * Also: when a single module owns both a QMenu *and* its child - * QActions, deleting the menu auto-deletes the actions. To avoid - * double-free we delete actions first **and let Qt sever the - * parent-child link** before the menu's own deleteLater runs. */ const bool shuttingDown = m_shutdownDone; @@ -2947,19 +3228,27 @@ void PluginManager::releaseTrayResourcesForModule(void* module_handle) if (m_closeFilters[i].module_handle == module_handle) m_closeFilters.removeAt(i); } - if (m_closeFilters.isEmpty() && m_closeFilterInstalled && - m_filteredMainWindow && !shuttingDown) { - m_filteredMainWindow->removeEventFilter(this); + if (m_closeFilters.isEmpty() && m_closeFilterInstalled && !shuttingDown) { + if (m_filteredMainWindow) + m_filteredMainWindow->removeEventFilter(this); + if (m_filteredShellWindow) + m_filteredShellWindow->removeEventFilter(this); m_closeFilterInstalled = false; } /* Tray icons — hide first so the platform plugin lets go of any - * embedded popup menu reference before we touch the QMenu. */ + * embedded popup menu reference before we touch the QMenu. Each + * tray now owns at most one QMenu directly (ABI 5's api_tray_set_menu + * rebuilds it in place instead of the plugin creating/owning it via + * the removed tray_menu_* family), so it is torn down right here + * alongside the icon — no separate menu/action registries needed + * any more. */ for (int i = m_trayIcons.size() - 1; i >= 0; --i) { if (m_trayIcons[i].module_handle != module_handle) continue; auto* icon = m_trayIcons[i].icon; auto* guard = m_trayIcons[i].guard; + auto* menu = m_trayIcons[i].menu; if (icon) { /* Detach the context menu *before* hiding; some Qt * platforms (XCB tray) re-enter the menu during hide @@ -2969,71 +3258,15 @@ void PluginManager::releaseTrayResourcesForModule(void* module_handle) if (!shuttingDown) icon->deleteLater(); } + if (menu && !shuttingDown) + menu->deleteLater(); if (guard && !shuttingDown) guard->deleteLater(); m_trayIcons.removeAt(i); } - /* Tray-menu actions — only delete in normal mode, AND only if the - * action's parent menu does NOT also belong to this module (the - * QMenu's destructor will sweep its own children). In shutdown - * mode we just forget about them; the process is going away. */ - if (!shuttingDown) { - /* Gather the menu handles owned by this module so we can skip - * actions whose parent will be deleted anyway. */ - QSet ownedMenus; - for (const auto& m : m_trayMenus) { - if (m.module_handle == module_handle && m.menu) - ownedMenus.insert(m.menu); - } - for (int i = m_trayActions.size() - 1; i >= 0; --i) { - if (m_trayActions[i].module_handle != module_handle) - continue; - QAction* a = m_trayActions[i].action; - if (a) { - if (!ownedMenus.contains(a->parent())) - a->deleteLater(); - /* else: parent menu will deleteLater itself below - * and Qt will free this action through QObject's - * normal parent-child cascade. */ - } - m_trayActions.removeAt(i); - } - } else { - /* Shutdown: just drop the records. */ - for (int i = m_trayActions.size() - 1; i >= 0; --i) { - if (m_trayActions[i].module_handle == module_handle) - m_trayActions.removeAt(i); - } - } - - /* Tray menus. - * - * Submenus are tracked in m_trayMenus too (added by - * api_tray_menu_add_submenu) but their parent is another QMenu in - * the same module. To avoid double-free we only call deleteLater - * on menus whose parent is NOT one of our own menus — Qt's - * parent-child cascade will sweep the rest. */ - if (!shuttingDown) { - QSet ownedMenus; - for (const auto& m : m_trayMenus) { - if (m.module_handle == module_handle && m.menu) - ownedMenus.insert(m.menu); - } - for (int i = m_trayMenus.size() - 1; i >= 0; --i) { - if (m_trayMenus[i].module_handle != module_handle) - continue; - QMenu* menu = m_trayMenus[i].menu; - if (menu && !ownedMenus.contains(menu->parent())) - menu->deleteLater(); - m_trayMenus.removeAt(i); - } - } else { - for (int i = m_trayMenus.size() - 1; i >= 0; --i) { - if (m_trayMenus[i].module_handle == module_handle) - m_trayMenus.removeAt(i); - } - } + /* ABI 5 — declarative UI surfaces owned by this module. */ + releaseSurfacesForModule(module_handle); /* S23 — instance running-state callbacks owned by this module. * Deleting each record's guard QObject severs the Qt connection @@ -3102,6 +3335,29 @@ void PluginManager::connectAppSignals() ev.page_handle = page; this->dispatchHook(MMCO_HOOK_INSTANCE_SETTINGS_PAGE_CREATED, &ev); + /* ABI 5 — every MMCO_UI_ANCHOR_INSTANCE_SETTINGS surface + * anchored to this instance is stacked as a titled section + * inside one host "Plugins" group, inserted into the + * existing "Workarounds" tab layout the same way + * GitVersioning/LinuxPerf used to inject their own group + * there directly (now done once, here, instead of by each + * plugin walking qApp->allWidgets()/findChild itself). */ + if (page && inst) { + if (QWidget* section = this->buildPluginsSectionWidget( + MMCO_UI_ANCHOR_INSTANCE_SETTINGS, inst->id())) { + if (auto* workaroundsLayout = page->findChild( + QStringLiteral("verticalLayout_8"))) { + auto* group = new QGroupBox(tr("Plugins")); + auto* groupLayout = new QVBoxLayout(group); + groupLayout->addWidget(section); + const int insertAt = qMax(0, workaroundsLayout->count() - 1); + workaroundsLayout->insertWidget(insertAt, group); + } else { + delete section; + } + } + } + if (!page) return; /* Capture the raw BaseInstance pointer + a copy of its id @@ -3410,34 +3666,75 @@ int PluginManager::api_tray_show_message(void* mh, void* tray_handle, return 0; } -int PluginManager::api_tray_set_menu(void* /*mh*/, void* tray_handle, - void* menu_handle) +int PluginManager::api_tray_set_menu(void* mh, void* tray_handle, + const char* json_menu_doc, + MMCOUiEventCallback cb, void* user_data) { - if (!tray_handle) + auto* r = rt(mh); + if (!r || !tray_handle) return -1; auto* tray = static_cast(tray_handle); - auto* menu = static_cast(menu_handle); + TrayRecord* rec = nullptr; + for (auto& tr : r->manager->m_trayIcons) { + if (tr.icon == tray_handle && tr.module_handle == mh) { + rec = &tr; + break; + } + } + if (!rec) + return -1; + + if (!json_menu_doc) { + /* Detach — the ABI 5 equivalent of the old "pass nullptr to + * detach" contract. */ + tray->setContextMenu(nullptr); + if (rec->menu) { + rec->menu->deleteLater(); + rec->menu = nullptr; + } + return 0; + } + + /* One QMenu per tray, owned by the host and rebuilt in place on + * every call — replaces the plugin building/owning a QMenu itself + * via the removed tray_menu_* family. */ + if (!rec->menu) + rec->menu = new QMenu(); + + PluginUiRenderer::EventSink sink; + if (cb) { + sink = [cb, user_data](const QString& nodeId, const QString& event, + const QString& valueJson) { + const QByteArray nid = nodeId.toUtf8(); + const QByteArray ev = event.toUtf8(); + const QByteArray val = valueJson.toUtf8(); + cb(user_data, "tray", nid.constData(), ev.constData(), + val.constData()); + }; + } + if (!PluginUiRenderer::buildTrayMenu(rec->menu, QString::fromUtf8(json_menu_doc), + sink)) + return -1; + + QMenu* menu = rec->menu; #ifdef Q_OS_WIN tray->setContextMenu(nullptr); - if (menu) { - QObject::disconnect(tray, &QSystemTrayIcon::activated, menu, nullptr); - QObject::connect( - tray, &QSystemTrayIcon::activated, menu, - [menu](QSystemTrayIcon::ActivationReason reason) { - if (reason != QSystemTrayIcon::Context) - return; - menu->winId(); - ::SetForegroundWindow( - reinterpret_cast(menu->winId())); - menu->popup(QCursor::pos()); - }); - } - return 0; + QObject::disconnect(tray, &QSystemTrayIcon::activated, menu, nullptr); + QObject::connect( + tray, &QSystemTrayIcon::activated, menu, + [menu](QSystemTrayIcon::ActivationReason reason) { + if (reason != QSystemTrayIcon::Context) + return; + menu->winId(); + ::SetForegroundWindow( + reinterpret_cast(menu->winId())); + menu->popup(QCursor::pos()); + }); #else tray->setContextMenu(menu); - return 0; #endif + return 0; } int PluginManager::api_tray_set_activation_cb(void* mh, void* tray_handle, @@ -3468,118 +3765,6 @@ int PluginManager::api_tray_set_activation_cb(void* mh, void* tray_handle, return -1; } -void* PluginManager::api_tray_menu_create(void* mh) -{ - auto* r = rt(mh); - if (!r) - return nullptr; - auto* menu = new QMenu(); - r->manager->m_trayMenus.append({mh, menu}); - return menu; -} - -int PluginManager::api_tray_menu_destroy(void* mh, void* menu_handle) -{ - auto* r = rt(mh); - if (!r || !menu_handle) - return -1; - auto& vec = r->manager->m_trayMenus; - for (int i = 0; i < vec.size(); ++i) { - if (vec[i].menu == menu_handle && vec[i].module_handle == mh) { - /* Drop any actions registered against this menu first. */ - auto& acts = r->manager->m_trayActions; - for (int j = acts.size() - 1; j >= 0; --j) { - if (acts[j].action && acts[j].action->parent() == - static_cast(menu_handle)) { - acts[j].action->deleteLater(); - acts.removeAt(j); - } - } - vec[i].menu->deleteLater(); - vec.removeAt(i); - return 0; - } - } - return -1; -} - -int PluginManager::api_tray_menu_clear(void* /*mh*/, void* menu_handle) -{ - if (!menu_handle) - return -1; - static_cast(menu_handle)->clear(); - return 0; -} - -int PluginManager::api_tray_menu_add_separator(void* /*mh*/, void* menu_handle) -{ - if (!menu_handle) - return -1; - static_cast(menu_handle)->addSeparator(); - return 0; -} - -void* PluginManager::api_tray_menu_add_action(void* mh, void* menu_handle, - const char* label, - const char* icon_name, - MMCOMenuActionCallback cb, - void* ud) -{ - auto* r = rt(mh); - if (!r || !menu_handle || !label) - return nullptr; - auto* menu = static_cast(menu_handle); - QAction* act = menu->addAction(QString::fromUtf8(label)); - if (icon_name && *icon_name) - act->setIcon(mmco_resolve_icon(icon_name)); - if (cb) { - QObject::connect(act, &QAction::triggered, act, [cb, ud]() { cb(ud); }); - } - r->manager->m_trayActions.append({mh, act}); - return act; -} - -int PluginManager::api_tray_menu_action_set_enabled(void* /*mh*/, - void* action_handle, - int enabled) -{ - if (!action_handle) - return -1; - static_cast(action_handle)->setEnabled(enabled != 0); - return 0; -} - -int PluginManager::api_tray_menu_action_set_text(void* /*mh*/, - void* action_handle, - const char* text) -{ - if (!action_handle) - return -1; - static_cast(action_handle) - ->setText(QString::fromUtf8(text ? text : "")); - return 0; -} - -void* PluginManager::api_tray_menu_add_submenu(void* mh, void* parent_menu, - const char* label, - const char* icon_name) -{ - auto* r = rt(mh); - if (!r || !parent_menu || !label) - return nullptr; - auto* parent = static_cast(parent_menu); - auto* child = parent->addMenu(QString::fromUtf8(label)); - if (!child) - return nullptr; - if (icon_name && *icon_name) - child->setIcon(mmco_resolve_icon(icon_name)); - /* Track in the per-module registry so shutdown / unload finds it. - * The QMenu is parented to `parent` so we don't deleteLater it - * during unload — the parent menu's cascade will. */ - r->manager->m_trayMenus.append({mh, child}); - return child; -} - /* ── S20 trampolines ─────────────────────────────────────────────── */ int PluginManager::api_main_window_install_close_filter( @@ -3596,15 +3781,17 @@ int PluginManager::api_main_window_install_close_filter( if (self->m_closeFilters[i].module_handle == mh) self->m_closeFilters.removeAt(i); } - if (self->m_closeFilters.isEmpty() && self->m_closeFilterInstalled && - self->m_filteredMainWindow) { - self->m_filteredMainWindow->removeEventFilter(self); + if (self->m_closeFilters.isEmpty() && self->m_closeFilterInstalled) { + if (self->m_filteredMainWindow) + self->m_filteredMainWindow->removeEventFilter(self); + if (self->m_filteredShellWindow) + self->m_filteredShellWindow->removeEventFilter(self); self->m_closeFilterInstalled = false; } return 0; } - if (!self->resolveMainWindow()) + if (!self->resolveMainWindow() && !self->resolveShellWindow()) return -1; self->m_closeFilters.append({mh, cb, user_data}); @@ -3617,13 +3804,20 @@ int PluginManager::api_main_window_show(void* mh) auto* r = rt(mh); if (!r) return -1; - QWidget* mw = r->manager->resolveMainWindow(); - if (!mw) - return -1; - mw->show(); - mw->raise(); - mw->activateWindow(); - return 0; + auto* self = r->manager; + if (QWidget* mw = self->resolveMainWindow()) { + mw->show(); + mw->raise(); + mw->activateWindow(); + return 0; + } + if (QWindow* window = self->resolveShellWindow()) { + window->show(); + window->raise(); + window->requestActivate(); + return 0; + } + return -1; } int PluginManager::api_main_window_hide(void* mh) @@ -3631,11 +3825,16 @@ int PluginManager::api_main_window_hide(void* mh) auto* r = rt(mh); if (!r) return -1; - QWidget* mw = r->manager->resolveMainWindow(); - if (!mw) - return -1; - mw->hide(); - return 0; + auto* self = r->manager; + if (QWidget* mw = self->resolveMainWindow()) { + mw->hide(); + return 0; + } + if (QWindow* window = self->resolveShellWindow()) { + window->hide(); + return 0; + } + return -1; } int PluginManager::api_main_window_is_visible(void* mh) @@ -3643,8 +3842,12 @@ int PluginManager::api_main_window_is_visible(void* mh) auto* r = rt(mh); if (!r) return 0; - QWidget* mw = r->manager->resolveMainWindow(); - return (mw && mw->isVisible()) ? 1 : 0; + auto* self = r->manager; + if (QWidget* mw = self->resolveMainWindow()) + return mw->isVisible() ? 1 : 0; + if (QWindow* window = self->resolveShellWindow()) + return window->isVisible() ? 1 : 0; + return 0; } /* ── S24 — Per-instance settings (ABI 3+) ────────────────────────── */ diff --git a/launcher/plugin/PluginManager.h b/launcher/plugin/PluginManager.h index 6d9c56b3..73731e5c 100644 --- a/launcher/plugin/PluginManager.h +++ b/launcher/plugin/PluginManager.h @@ -23,9 +23,13 @@ #include "plugin/PluginMetadata.h" #include "plugin/PluginHooks.h" #include "plugin/PluginAPI.h" +#include "plugin/PluginUiRenderer.h" #include "news/NewsEntry.h" +#include +#include +#include #include #include #include @@ -41,12 +45,14 @@ #include #include +class BasePage; class NewsChecker; class QAction; class QEvent; class QMenu; class QSystemTrayIcon; class QWidget; +class QWindow; /* * PluginManager — owns the plugin lifecycle and provides the bridge @@ -114,6 +120,69 @@ class PluginManager : public QObject void setModuleDisabled(const QString& moduleName, bool disabled); QSet disabledModuleNames() const; + /* + * ─── ABI 5 — Declarative UI surfaces ────────────────────────────── + * + * Every ui_surface_create() call is recorded here as a SurfaceInfo + * — anchor, optional instance-id context, title/icon, and the + * current "mmco-ui/1" JSON document — independent of whether the + * widget renderer currently has anything on screen for it. This is + * the seam a future QML-based shell renders from instead of the + * QWidget tree PluginUiRenderer builds today: read the snapshot, + * watch surfacesChanged() for updates, and call deliverUiEvent() + * to report clicks/changes back to the owning plugin exactly the + * way a rendered QWidget does internally. + */ + struct SurfaceInfo { + void* handle = nullptr; /* opaque; same value ui_surface_create returned */ + QString surfaceId; + int anchor = 0; /* MMCOUiAnchor */ + QString anchorContext; /* instance id, or empty for GLOBAL_SETTINGS */ + QString title; + QString iconName; + QString document; /* current "mmco-ui/1" JSON, as text */ + }; + + /* + * Snapshot of every live surface. `anchor` filters to one + * MMCOUiAnchor value, or pass -1 for "any". `anchorContext` + * filters to an exact instance id, or pass a default-constructed + * (null) QString for "any context" — a real-but-empty QString("") + * matches only GLOBAL_SETTINGS surfaces, which always have an + * empty context. + */ + QList surfaces(int anchor = -1, + const QString& anchorContext = QString()) const; + + /* + * Deliver a synthetic UI event to a surface's registered + * MMCOUiEventCallback — what a future QML renderer calls instead + * of relying on PluginUiRenderer's own Qt signal/slot wiring. + * No-op if surfaceId names no live surface. + */ + void deliverUiEvent(const QString& surfaceId, const QString& nodeId, + const QString& event, const QString& valueJson); + + /* + * Host-internal widget builders — used by InstancePageProvider, + * Application's global-settings page provider, and the + * INSTANCE_SETTINGS_PAGE_CREATED bridge below to turn the surfaces + * above into the current widget UI. Each call renders fresh + * QWidgets from the surface's current document; every returned + * page/widget is caller-owned. + */ + + /* One BasePage per MMCO_UI_ANCHOR_INSTANCE_PAGE surface anchored to + * `instanceId` — these become their own tabs in the instance + * window, alongside GitVersioningPage-style plugin pages. */ + QList createInstancePages(const QString& instanceId); + + /* A single "Plugins" BasePage stacking every + * MMCO_UI_ANCHOR_GLOBAL_SETTINGS surface as a titled section, or + * nullptr if there are none — inserted into the global Settings + * dialog's page list. */ + BasePage* createGlobalSettingsPluginsPage(); + /* * ScratchString — the per-module scratch buffer that backs every * `const char*` getter in the plugin API. @@ -180,6 +249,9 @@ class PluginManager : public QObject void moduleLoaded(const QString& name); void moduleUnloaded(const QString& name); void moduleError(const QString& name, const QString& error); + /* Any surface was created/updated/destroyed — a future QML shell + * (or anything else watching surfaces()) re-reads on this. */ + void surfacesChanged(); private: /* Build an MMCOContext for a specific module */ @@ -389,43 +461,24 @@ class PluginManager : public QObject const char* prompt, const char* def); static int api_ui_confirm_dialog(void* mh, const char* title, const char* msg); - static int api_ui_register_instance_action(void* mh, const char* text, - const char* tooltip, - const char* icon_name, - const char* page_id); - static int api_ui_register_instance_action_cb(void* mh, const char* text, - const char* tooltip, - const char* icon_name, - void (*cb)(void* ud), - void* ud); - - /* Section 13: UI Page Builder */ - static void* api_ui_page_create(void* mh, const char* id, const char* name, - const char* icon); - static int api_ui_page_add_to_list(void* mh, void* page, void* list); - static void* api_ui_layout_create(void* mh, void* parent, int type); - static int api_ui_layout_add_widget(void* mh, void* layout, void* widget); - static int api_ui_layout_add_layout(void* mh, void* parent, void* child); - static int api_ui_layout_add_spacer(void* mh, void* layout, int horizontal); - static int api_ui_page_set_layout(void* mh, void* page, void* layout); - static void* api_ui_button_create(void* mh, void* parent, const char* text, - const char* icon, MMCOButtonCallback cb, - void* ud); - static int api_ui_button_set_enabled(void* mh, void* btn, int enabled); - static int api_ui_button_set_text(void* mh, void* btn, const char* text); - static void* api_ui_label_create(void* mh, void* parent, const char* text); - static int api_ui_label_set_text(void* mh, void* label, const char* text); - static void* api_ui_tree_create(void* mh, void* parent, const char** cols, - int ncols, MMCOTreeSelectionCallback cb, - void* ud); - static int api_ui_tree_clear(void* mh, void* tree); - static int api_ui_tree_add_row(void* mh, void* tree, const char** vals, - int ncols); - static int api_ui_tree_selected_row(void* mh, void* tree); - static int api_ui_tree_set_row_data(void* mh, void* tree, int row, - int64_t data); - static int64_t api_ui_tree_get_row_data(void* mh, void* tree, int row); - static int api_ui_tree_row_count(void* mh, void* tree); + + /* Section 33: Declarative UI surfaces (ABI 5) */ + static void* api_ui_surface_create(void* mh, int anchor, + const char* anchor_context, + const char* title, const char* icon_name, + const char* json_doc, + MMCOUiEventCallback cb, void* user_data); + static int api_ui_surface_update(void* mh, void* surface, + const char* json_doc); + static int api_ui_surface_set(void* mh, void* surface, const char* node_id, + const char* json_props); + static int api_ui_surface_set_rows(void* mh, void* surface, + const char* node_id, + const char* json_rows); + static int api_ui_surface_destroy(void* mh, void* surface); + static int api_ui_modal_run(void* mh, const char* title, + const char* json_doc, char* out_result_json, + int out_buf_size); /* Section 14: Utility */ static const char* api_get_app_version(void* mh); @@ -533,25 +586,11 @@ class PluginManager : public QObject const char* title, const char* message, int icon_type, int msecs); static int api_tray_set_menu(void* mh, void* tray_handle, - void* menu_handle); + const char* json_menu_doc, + MMCOUiEventCallback cb, void* user_data); static int api_tray_set_activation_cb(void* mh, void* tray_handle, MMCOTrayActivationCallback cb, void* ud); - static void* api_tray_menu_create(void* mh); - static int api_tray_menu_destroy(void* mh, void* menu_handle); - static int api_tray_menu_clear(void* mh, void* menu_handle); - static int api_tray_menu_add_separator(void* mh, void* menu_handle); - static void* api_tray_menu_add_action(void* mh, void* menu_handle, - const char* label, - const char* icon_name, - MMCOMenuActionCallback cb, void* ud); - static int api_tray_menu_action_set_enabled(void* mh, void* action_handle, - int enabled); - static int api_tray_menu_action_set_text(void* mh, void* action_handle, - const char* text); - static void* api_tray_menu_add_submenu(void* mh, void* parent_menu, - const char* label, - const char* icon_name); /* Section 20: Main window helpers */ static int api_main_window_install_close_filter( @@ -592,8 +631,10 @@ class PluginManager : public QObject private: /* NOTE: the instance toolbar actions a plugin could once register here - * are gone along with the API that fed them; see the deprecation note - * on api_ui_register_instance_action() in PluginManager.cpp. */ + * are gone along with the API that fed them (ui_register_instance_action + * / _cb, deprecated no-ops in ABI 3-4, removed entirely in ABI 5). A + * plugin's per-instance UI belongs on an instance-window page instead + * — see ui_surface_create's MMCO_UI_ANCHOR_INSTANCE_PAGE in PluginAPI.h. */ /* Pending launch modifications (set by plugins during PRE_LAUNCH hooks). * @@ -609,21 +650,19 @@ class PluginManager : public QObject * entries; see newsChecker() above. */ /* S19 / S20 — system-tray and main-window helpers state. - * All tray icons, menus, actions and close filters are tracked per - * owning module so PluginManager can release them en masse when a - * module is unloaded — preventing leaks and dangling Qt parents. */ + * All tray icons and close filters are tracked per owning module so + * PluginManager can release them en masse when a module is + * unloaded — preventing leaks and dangling Qt parents. */ struct TrayRecord { void* module_handle; QSystemTrayIcon* icon; QObject* guard; /* relay for activation signal */ - }; - struct MenuRecord { - void* module_handle; - QMenu* menu; - }; - struct ActionRecord { - void* module_handle; - QAction* action; + /* ABI 5 — the one QMenu a tray's declarative menu doc is + * rendered into by api_tray_set_menu(); rebuilt in place on + * every call instead of the plugin creating/owning it via the + * removed tray_menu_* family. nullptr until the first + * tray_set_menu() call. */ + QMenu* menu = nullptr; }; struct CloseFilterRecord { void* module_handle; @@ -631,11 +670,14 @@ class PluginManager : public QObject void* user_data; }; QVector m_trayIcons; - QVector m_trayMenus; - QVector m_trayActions; QVector m_closeFilters; bool m_closeFilterInstalled = false; QPointer m_filteredMainWindow; + /* The QML shell's root QWindow, cached the same way + * m_filteredMainWindow is -- see resolveShellWindow(). Only ever set + * when there is no widget MainWindow (the QML shell is the active + * UI); both can't be non-null at once. */ + QPointer m_filteredShellWindow; /* S23 (ABI 3+) — per-module per-instance running-state callbacks. * @@ -657,12 +699,70 @@ class PluginManager : public QObject }; QVector m_instanceRunning; + /* ─── ABI 5 — Declarative UI surfaces ───────────────────────────── + * + * One record per ui_surface_create() call. `doc` is the canonical, + * always-current parsed document — the single source of truth + * every accessor (surfaces(), the widget builders below) reads + * from. `mountedRoot`/`mountedRenderer` track whichever rendered + * widget is *currently on screen* for this surface, if any: since + * every page/dialog that displays a surface is rebuilt fresh each + * time it is opened (same as the old per-plugin BasePage pattern), + * ui_surface_update/_set/_set_rows always patch `doc` and — when a + * view happens to be mounted right now — also patch that live + * widget immediately, so e.g. GitVersioning's row-selection -> + * button-enabled wiring stays instant while the instance page is + * open. mountedRoot is a QPointer so it self-clears the moment + * Qt tears down that view; mountedRenderer is only ever + * dereferenced while mountedRoot is still non-null (they share the + * same lifetime — see PluginManager.cpp's RendererOwner). */ + struct SurfaceRecord { + void* module_handle = nullptr; + QString surfaceId; + int anchor = 0; + QString anchorContext; + QString title; + QString iconName; + QJsonObject doc; + MMCOUiEventCallback cb = nullptr; + void* userData = nullptr; + QPointer mountedRoot; + PluginUiRenderer::RenderedSurface* mountedRenderer = nullptr; + }; + std::vector> m_surfaces; + int m_nextSurfaceSeq = 0; + + /* Find the SurfaceRecord a `void* surface` handle refers to (the + * handle is that record's own stable heap address), or nullptr. */ + SurfaceRecord* findSurface(void* module_handle, void* surface); + /* Build the EventSink that forwards PluginUiRenderer callbacks into + * a surface's MMCOUiEventCallback. */ + static PluginUiRenderer::EventSink makeSurfaceSink(SurfaceRecord* rec); + /* Render every surface at (anchor, anchorContext) into one + * QWidget stacking a titled QGroupBox per surface, or nullptr if + * there are none. Shared by createGlobalSettingsPluginsPage() and + * the INSTANCE_SETTINGS_PAGE_CREATED bridge in connectAppSignals(). */ + QWidget* buildPluginsSectionWidget(int anchor, const QString& anchorContext); + /* Release every SurfaceRecord owned by `module_handle` — called + * from releaseTrayResourcesForModule() so ABI 5 surfaces get the + * same per-module teardown as tray icons/menus. */ + void releaseSurfacesForModule(void* module_handle); + /* Resolve the launcher's main window (objectName == "MainWindow"), * cached for the lifetime of the QPointer. Returns nullptr if the - * window has not been built yet. */ + * window has not been built yet, or when the QML shell is the + * active UI instead (see resolveShellWindow()). */ QWidget* resolveMainWindow(); - /* Make sure our QObject::eventFilter is installed on the main - * window. Safe to call multiple times — installs at most once. */ + /* Resolve the QML shell's top-level QWindow via Application, cached + * for the lifetime of the QPointer — the generalised counterpart to + * resolveMainWindow() for main_window_show/hide/is_visible and the + * close filter when the widget MainWindow does not exist. Returns + * nullptr before the shell has been shown, or when the widget + * MainWindow is the active UI instead. */ + QWindow* resolveShellWindow(); + /* Make sure our QObject::eventFilter is installed on whichever + * top-level window is active (widget MainWindow or the QML shell's + * window). Safe to call multiple times — installs at most once. */ void ensureCloseFilterInstalled(); /* Release all S19/S20/S23 resources owned by the given module * handle. Called from shutdownAll() right before mmco_unload(). */ diff --git a/launcher/plugin/PluginMetadata.h b/launcher/plugin/PluginMetadata.h index cec51fbc..fbab62e0 100644 --- a/launcher/plugin/PluginMetadata.h +++ b/launcher/plugin/PluginMetadata.h @@ -57,6 +57,12 @@ enum class PluginDisableReason { DependencyCycle, /* This module is part of a dependency cycle */ SupersededByCore, /* Functionality moved into the launcher itself — see plugin/CoreSupersededPlugins.h */ + AbiTooOld, /* Built against an ABI older than + MMCO_ABI_VERSION_MIN -- the module predates a + floor raise and needs the author to update it */ + AbiTooNew, /* Built against an ABI newer than + MMCO_ABI_VERSION -- built for a MeshMC newer + than this one */ }; struct PluginDependencyRecord { diff --git a/launcher/plugin/PluginSurfaceModel.cpp b/launcher/plugin/PluginSurfaceModel.cpp new file mode 100644 index 00000000..d98ab2c6 --- /dev/null +++ b/launcher/plugin/PluginSurfaceModel.cpp @@ -0,0 +1,238 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "plugin/PluginSurfaceModel.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + /* Mirrors PluginUiRenderer.cpp's (anonymous-namespace, so unreachable + * from here) jsonQuoteString(): the widget renderer's own convention + * for a string value_json (text_field/choice change, list select/ + * activate). Duplicated rather than exported from PluginUiRenderer for + * one extra caller — six lines, and the convention itself is what + * needs to match, not the code. */ + QString quoteJsonString(const QString& s) + { + QJsonArray tmp; + tmp.append(s); + QByteArray bytes = QJsonDocument(tmp).toJson(QJsonDocument::Compact); + if (bytes.size() >= 2) + bytes = bytes.mid(1, bytes.size() - 2); + return QString::fromUtf8(bytes); + } +} // namespace + +PluginSurfaceModel::PluginSurfaceModel(PluginManager* manager, int anchor, + const QString& anchorContext, + QObject* parent) + : QAbstractListModel(parent), m_manager(manager), m_anchor(anchor), + m_anchorContext(anchorContext) +{ + if (m_manager) { + connect(m_manager, &PluginManager::surfacesChanged, this, + &PluginSurfaceModel::refresh); + } + /* Virtual dispatch during construction always resolves to this class, + * never a subclass's override — so a fetchSurfaces() test seam (see + * PluginSurfaceModel.h) only takes effect once construction has + * finished and the subclass calls refresh() itself, same as everyone + * else changing what fetchSurfaces() would return. This first call + * just seeds m_rows from `manager` (or empty, with a null one). */ + refresh(); +} + +int PluginSurfaceModel::anchor() const +{ + return m_anchor; +} + +void PluginSurfaceModel::setAnchor(int anchor) +{ + if (m_anchor == anchor) + return; + m_anchor = anchor; + emit anchorChanged(); + refresh(); +} + +QString PluginSurfaceModel::anchorContext() const +{ + return m_anchorContext; +} + +void PluginSurfaceModel::setAnchorContext(const QString& anchorContext) +{ + if (m_anchorContext == anchorContext) + return; + m_anchorContext = anchorContext; + emit anchorContextChanged(); + refresh(); +} + +int PluginSurfaceModel::rowCount(const QModelIndex& parent) const +{ + if (parent.isValid()) + return 0; + return m_rows.size(); +} + +QVariant PluginSurfaceModel::data(const QModelIndex& index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= m_rows.size()) + return QVariant(); + + const Row& row = m_rows.at(index.row()); + switch (role) { + case SurfaceIdRole: + return row.surfaceId; + case TitleRole: + return row.title; + case IconNameRole: + return row.iconName; + case AnchorRole: + return row.anchor; + case AnchorContextRole: + return row.anchorContext; + case DocumentRole: + return row.document; + case RevisionRole: + return row.revision; + default: + return QVariant(); + } +} + +QHash PluginSurfaceModel::roleNames() const +{ + return { + { SurfaceIdRole, "surfaceId" }, + { TitleRole, "title" }, + { IconNameRole, "iconName" }, + { AnchorRole, "anchor" }, + { AnchorContextRole, "anchorContext" }, + { DocumentRole, "document" }, + { RevisionRole, "revision" }, + }; +} + +QList PluginSurfaceModel::fetchSurfaces() const +{ + if (!m_manager) + return {}; + return m_manager->surfaces(m_anchor, m_anchorContext); +} + +void PluginSurfaceModel::refresh() +{ + beginResetModel(); + + const auto infos = fetchSurfaces(); + QSet seen; + seen.reserve(infos.size()); + m_rows.clear(); + m_rows.reserve(infos.size()); + + for (const auto& info : infos) { + seen.insert(info.surfaceId); + + int& revision = m_revisions[info.surfaceId]; + QString& lastDocument = m_lastDocuments[info.surfaceId]; + if (lastDocument != info.document) { + ++revision; + lastDocument = info.document; + } + + Row row; + row.surfaceId = info.surfaceId; + row.title = info.title; + row.iconName = info.iconName; + row.anchor = info.anchor; + row.anchorContext = info.anchorContext; + row.revision = revision; + + QJsonParseError err{}; + const QJsonDocument doc = + QJsonDocument::fromJson(info.document.toUtf8(), &err); + if (err.error == QJsonParseError::NoError && doc.isObject()) + row.document = doc.object().toVariantMap(); + + m_rows.append(row); + } + + // Drop bookkeeping for surfaces that no longer exist, so a destroyed + // surface's id doesn't linger in these maps forever. + for (auto it = m_revisions.begin(); it != m_revisions.end();) { + if (seen.contains(it.key())) + ++it; + else + it = m_revisions.erase(it); + } + for (auto it = m_lastDocuments.begin(); it != m_lastDocuments.end();) { + if (seen.contains(it.key())) + ++it; + else + it = m_lastDocuments.erase(it); + } + + endResetModel(); +} + +QString PluginSurfaceModel::valueToJson(const QVariant& value) +{ + if (!value.isValid() || value.isNull()) + return QString(); + + switch (value.userType()) { + case QMetaType::Bool: + return value.toBool() ? QStringLiteral("true") : QStringLiteral("false"); + case QMetaType::Short: + case QMetaType::UShort: + case QMetaType::Int: + case QMetaType::UInt: + case QMetaType::Long: + case QMetaType::ULong: + case QMetaType::LongLong: + case QMetaType::ULongLong: + return QString::number(value.toLongLong()); + case QMetaType::Double: + case QMetaType::Float: + return QString::number(value.toDouble()); + default: + break; + } + // Anything else -- a string, or a list row's id -- quoted the same way + // a text_field/choice change or a list select/activate is. + return quoteJsonString(value.toString()); +} + +void PluginSurfaceModel::sendEvent(const QString& surfaceId, + const QString& nodeId, const QString& event, + const QVariant& value) +{ + if (!m_manager) + return; + m_manager->deliverUiEvent(surfaceId, nodeId, event, valueToJson(value)); +} diff --git a/launcher/plugin/PluginSurfaceModel.h b/launcher/plugin/PluginSurfaceModel.h new file mode 100644 index 00000000..8fb7447f --- /dev/null +++ b/launcher/plugin/PluginSurfaceModel.h @@ -0,0 +1,168 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "plugin/PluginManager.h" + +#include +#include +#include +#include +#include +#include +#include + +/* + * PluginSurfaceModel — a QAbstractListModel over + * PluginManager::surfaces(anchor, anchorContext), the seam ABI 5's + * declarative UI surfaces already expose for a renderer that isn't + * PluginUiRenderer's QWidget tree. One row per live surface at the + * (anchor, anchorContext) this model was created for; QmlShell hands one + * out per pair via QmlShell::pluginSurfaces() (see that class for why the + * factory indirection exists — this class lives in MeshMC_logic, next to + * PluginManager, and MeshMC_qml cannot see either). + * + * Refresh strategy: every PluginManager::surfacesChanged() re-reads the + * whole (anchor, anchorContext) slice and does a full beginResetModel() / + * endResetModel(), rather than diffing old/new rows into targeted + * inserts/removes/dataChanged(). surfacesChanged() carries no information + * about *what* changed — not even which surface — so anything smarter + * would mean tracking our own shadow copy of every surface's document just + * to diff against it, for an event that fires at most once per user click + * or plugin-initiated patch. A full reset is O(surfaces at this anchor), + * which is a handful of rows in the worst case (see GLOBAL_SETTINGS + * stacking every plugin's section into one page today) — simpler, and + * cheap enough not to matter. + * + * `revision` exists so a QML delegate bound to `document` can tell *this* + * surface's document changed (as opposed to some other row in the same + * reset) without comparing the JSON itself: it is bumped once per surface + * whenever that surface's raw document text differs from what the model + * last saw, and carried forward across resets by surface id. + */ +class PluginSurfaceModel : public QAbstractListModel +{ + Q_OBJECT + + Q_PROPERTY(int anchor READ anchor WRITE setAnchor NOTIFY anchorChanged) + Q_PROPERTY(QString anchorContext READ anchorContext WRITE setAnchorContext + NOTIFY anchorContextChanged) + + public: + enum Role { + SurfaceIdRole = Qt::UserRole + 1, + TitleRole, + IconNameRole, + AnchorRole, + AnchorContextRole, + DocumentRole, + RevisionRole, + }; + + /* + * `manager` is not owned and must outlive this model — true for every + * instance QmlShell::pluginSurfaces() hands out, since Application + * destroys its QmlShell (and everything it cached) before its + * PluginManager (see Application.h's member order). `anchor` of -1 + * means "every anchor"; a null (default-constructed) `anchorContext` + * means "every context" — both match PluginManager::surfaces()'s own + * filter semantics exactly (a real-but-empty QString("") matches only + * GLOBAL_SETTINGS surfaces, which always have an empty context). + */ + explicit PluginSurfaceModel(PluginManager* manager, int anchor = -1, + const QString& anchorContext = QString(), + QObject* parent = nullptr); + + int anchor() const; + void setAnchor(int anchor); + QString anchorContext() const; + void setAnchorContext(const QString& anchorContext); + + int rowCount(const QModelIndex& parent = QModelIndex()) const override; + QVariant data(const QModelIndex& index, + int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + + /* + * Serialises `value` the same way PluginUiRenderer.cpp's widget + * renderer serialises a node's live value into value_json for + * MMCOUiEventCallback: a bool becomes the bare `true`/`false` literal, + * a number becomes its plain text, anything else (string, a list row's + * id, …) becomes a quoted JSON string literal. An invalid/null QVariant + * — a button click, which carries no value — becomes the empty string, + * exactly like PluginUiRenderer's own button handler passes to sink(). + */ + static QString valueToJson(const QVariant& value); + + /* + * Delivers a UI event to the plugin behind `surfaceId`, the QML + * equivalent of PluginUiRenderer's Qt signal handlers calling `sink()` + * — value is serialised with valueToJson() first. No-op (via + * PluginManager::deliverUiEvent()) if surfaceId names no live surface, + * or if this model was built without a manager (see the test seam + * below). + */ + Q_INVOKABLE void sendEvent(const QString& surfaceId, const QString& nodeId, + const QString& event, const QVariant& value); + + signals: + void anchorChanged(); + void anchorContextChanged(); + + protected: + /* + * Seam for tests: by default forwards to + * `m_manager->surfaces(m_anchor, m_anchorContext)`. A test subclass + * overrides this to feed a canned SurfaceInfo list without a real + * PluginManager backed by loaded plugins (see + * PluginSurfaceModel_test.cpp), then calls the also-protected + * refresh() to re-read it. + */ + virtual QList fetchSurfaces() const; + + /* Re-reads fetchSurfaces() and resets the model; see the refresh + * strategy note above. Connected to PluginManager::surfacesChanged() + * in the constructor when `manager` is non-null. */ + void refresh(); + + private: + struct Row { + QString surfaceId; + QString title; + QString iconName; + int anchor = 0; + QString anchorContext; + QVariantMap document; + int revision = 0; + }; + + PluginManager* m_manager; + int m_anchor; + QString m_anchorContext; + QVector m_rows; + + /* Per-surface-id bookkeeping carried across refresh()es so `revision` + * keeps counting even though m_rows itself is rebuilt from scratch + * every time (see the refresh-strategy note above). Pruned to the + * surface ids seen in the most recent refresh() so a destroyed + * surface's id doesn't linger forever. */ + QHash m_revisions; + QHash m_lastDocuments; +}; diff --git a/launcher/plugin/PluginSurfaceModel_test.cpp b/launcher/plugin/PluginSurfaceModel_test.cpp new file mode 100644 index 00000000..ca310221 --- /dev/null +++ b/launcher/plugin/PluginSurfaceModel_test.cpp @@ -0,0 +1,226 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include + +#include +#include +#include + +#include "plugin/PluginSurfaceModel.h" + +namespace +{ + /* Feeds a canned SurfaceInfo list instead of reading a real + * PluginManager's — see PluginSurfaceModel.h's fetchSurfaces() doc + * comment for why this is the model's test seam. Built with a null + * manager: nothing here ever needs one, since fetchSurfaces() is + * fully overridden and sendEvent()/deliverUiEvent() is exercised + * separately as a safe no-op. */ + class TestSurfaceModel : public PluginSurfaceModel + { + public: + TestSurfaceModel() : PluginSurfaceModel(nullptr) {} + + void setCanned(QList infos) + { + m_canned = std::move(infos); + refresh(); + } + + protected: + QList fetchSurfaces() const override + { + return m_canned; + } + + private: + QList m_canned; + }; + + PluginManager::SurfaceInfo makeInfo(const QString& id, const QString& doc) + { + PluginManager::SurfaceInfo info; + info.surfaceId = id; + info.anchor = 0; + info.anchorContext = QStringLiteral("inst-1"); + info.title = QStringLiteral("Title-") + id; + info.iconName = QStringLiteral("icon"); + info.document = doc; + return info; + } + + const char* kMinimalDoc = + "{\"type\":\"mmco-ui/1\",\"root\":{\"type\":\"column\",\"id\":\"root\"," + "\"children\":[]}}"; +} // namespace + +class PluginSurfaceModelTest : public QObject +{ + Q_OBJECT + + private slots: + void rowsAndRoles(); + void revisionTracksDocumentChanges(); + void propertiesResetOnChange(); + void sendEventWithoutManagerIsNoop(); + + void valueToJson_data(); + void valueToJson(); +}; + +void PluginSurfaceModelTest::rowsAndRoles() +{ + TestSurfaceModel model; + model.setCanned({ makeInfo(QStringLiteral("sf-1"), QString::fromUtf8(kMinimalDoc)) }); + + QCOMPARE(model.rowCount(), 1); + const QModelIndex idx = model.index(0, 0); + QVERIFY(idx.isValid()); + + QCOMPARE(model.data(idx, PluginSurfaceModel::SurfaceIdRole).toString(), + QStringLiteral("sf-1")); + QCOMPARE(model.data(idx, PluginSurfaceModel::TitleRole).toString(), + QStringLiteral("Title-sf-1")); + QCOMPARE(model.data(idx, PluginSurfaceModel::IconNameRole).toString(), + QStringLiteral("icon")); + QCOMPARE(model.data(idx, PluginSurfaceModel::AnchorContextRole).toString(), + QStringLiteral("inst-1")); + + // The document comes back as a QVariantMap QML can walk straight into + // root/type/id/props/children without any JSON parsing of its own. + const QVariantMap doc = + model.data(idx, PluginSurfaceModel::DocumentRole).toMap(); + QCOMPARE(doc.value(QStringLiteral("type")).toString(), + QStringLiteral("mmco-ui/1")); + const QVariantMap root = doc.value(QStringLiteral("root")).toMap(); + QCOMPARE(root.value(QStringLiteral("type")).toString(), + QStringLiteral("column")); + QCOMPARE(root.value(QStringLiteral("id")).toString(), QStringLiteral("root")); + + // A brand new surface id starts at revision 1, not 0 -- "has a + // document" and "revision assigned" happen together. + QCOMPARE(model.data(idx, PluginSurfaceModel::RevisionRole).toInt(), 1); + + // roleNames() names every role a QML delegate would bind to by name. + const auto roles = model.roleNames(); + QCOMPARE(roles.value(PluginSurfaceModel::SurfaceIdRole), + QByteArray("surfaceId")); + QCOMPARE(roles.value(PluginSurfaceModel::DocumentRole), + QByteArray("document")); + QCOMPARE(roles.value(PluginSurfaceModel::RevisionRole), + QByteArray("revision")); +} + +void PluginSurfaceModelTest::revisionTracksDocumentChanges() +{ + TestSurfaceModel model; + const QString docA = QStringLiteral("{\"a\":1}"); + const QString docB = QStringLiteral("{\"a\":2}"); + + model.setCanned({ makeInfo(QStringLiteral("sf-1"), docA) }); + QCOMPARE(model.data(model.index(0, 0), PluginSurfaceModel::RevisionRole).toInt(), + 1); + + // Re-reading the same document text (a surfacesChanged() fired by some + // unrelated surface, say) must not bump a revision nothing changed. + model.setCanned({ makeInfo(QStringLiteral("sf-1"), docA) }); + QCOMPARE(model.data(model.index(0, 0), PluginSurfaceModel::RevisionRole).toInt(), + 1); + + // An actual document change (ui_surface_update/_set/_set_rows) bumps it. + model.setCanned({ makeInfo(QStringLiteral("sf-1"), docB) }); + QCOMPARE(model.data(model.index(0, 0), PluginSurfaceModel::RevisionRole).toInt(), + 2); + + // The surface disappearing (ui_surface_destroy) drops its bookkeeping; + // a later surface reusing the id (never happens in practice -- ids are + // sequential -- but nothing stops a test) starts fresh. + model.setCanned({}); + QCOMPARE(model.rowCount(), 0); + model.setCanned({ makeInfo(QStringLiteral("sf-1"), docB) }); + QCOMPARE(model.data(model.index(0, 0), PluginSurfaceModel::RevisionRole).toInt(), + 1); +} + +void PluginSurfaceModelTest::propertiesResetOnChange() +{ + TestSurfaceModel model; + QSignalSpy anchorSpy(&model, &PluginSurfaceModel::anchorChanged); + QSignalSpy contextSpy(&model, &PluginSurfaceModel::anchorContextChanged); + + QCOMPARE(model.anchor(), -1); + QVERIFY(model.anchorContext().isNull()); + + model.setAnchor(2); + QCOMPARE(model.anchor(), 2); + QCOMPARE(anchorSpy.count(), 1); + + // Setting the same value again is a no-op -- no redundant signal / reset. + model.setAnchor(2); + QCOMPARE(anchorSpy.count(), 1); + + model.setAnchorContext(QStringLiteral("inst-2")); + QCOMPARE(model.anchorContext(), QStringLiteral("inst-2")); + QCOMPARE(contextSpy.count(), 1); +} + +void PluginSurfaceModelTest::sendEventWithoutManagerIsNoop() +{ + TestSurfaceModel model; + // Must not crash: sendEvent() guards on a null manager the same way + // PluginManager::deliverUiEvent() guards on an unknown surfaceId. + model.sendEvent(QStringLiteral("sf-1"), QStringLiteral("node"), + QStringLiteral("click"), QVariant()); +} + +void PluginSurfaceModelTest::valueToJson_data() +{ + QTest::addColumn("value"); + QTest::addColumn("expected"); + + // Button click: no value at all. + QTest::newRow("invalid") << QVariant() << QString(); + // Toggle change. + QTest::newRow("bool_true") << QVariant(true) << QStringLiteral("true"); + QTest::newRow("bool_false") << QVariant(false) << QStringLiteral("false"); + // number_field change. + QTest::newRow("int") << QVariant(42) << QStringLiteral("42"); + QTest::newRow("negative_int") << QVariant(-7) << QStringLiteral("-7"); + QTest::newRow("double") << QVariant(3.5) << QStringLiteral("3.5"); + // text_field / choice change, list select/activate (a row id). + QTest::newRow("string") << QVariant(QStringLiteral("abc")) + << QStringLiteral("\"abc\""); + QTest::newRow("string_needs_escaping") + << QVariant(QStringLiteral("say \"hi\"")) << QString::fromUtf8( + "\"say \\\"hi\\\"\""); + QTest::newRow("empty_string") << QVariant(QStringLiteral("")) + << QStringLiteral("\"\""); +} + +void PluginSurfaceModelTest::valueToJson() +{ + QFETCH(QVariant, value); + QFETCH(QString, expected); + QCOMPARE(PluginSurfaceModel::valueToJson(value), expected); +} + +QTEST_GUILESS_MAIN(PluginSurfaceModelTest) + +#include "PluginSurfaceModel_test.moc" diff --git a/launcher/plugin/PluginUiRenderer.cpp b/launcher/plugin/PluginUiRenderer.cpp new file mode 100644 index 00000000..2aa702cb --- /dev/null +++ b/launcher/plugin/PluginUiRenderer.cpp @@ -0,0 +1,558 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "plugin/PluginUiRenderer.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + /* Encode `s` as a JSON string literal (quoted, escaped) — used for + * every value_json payload whose value is a string. */ + QString jsonQuoteString(const QString& s) + { + QJsonArray tmp; + tmp.append(s); + QByteArray bytes = QJsonDocument(tmp).toJson(QJsonDocument::Compact); + if (bytes.size() >= 2) + bytes = bytes.mid(1, bytes.size() - 2); + return QString::fromUtf8(bytes); + } + + /* Clear and rebuild a `list` node's rows. Shared by initial render + * and setRows() so the two never drift apart. */ + void populateListRows(QTreeWidget* tree, const QJsonArray& rows) + { + tree->clear(); + for (const QJsonValue& rv : rows) { + const QJsonObject row = rv.toObject(); + const QJsonArray cells = row.value(QStringLiteral("cells")).toArray(); + auto* item = new QTreeWidgetItem(tree); + for (int i = 0; i < cells.size(); ++i) + item->setText(i, cells.at(i).toString()); + item->setData(0, Qt::UserRole, row.value(QStringLiteral("id")).toString()); + if (row.contains(QStringLiteral("data"))) + item->setData(0, Qt::UserRole + 1, + row.value(QStringLiteral("data")).toString()); + } + } +} // namespace + +PluginUiRenderer::RenderedSurface::RenderedSurface(EventSink sink) + : m_sink(std::move(sink)), m_root(new QWidget()) +{ +} + +PluginUiRenderer::RenderedSurface::~RenderedSurface() +{ + /* m_root may already be gone: a parent it was placed under deletes + * its children before sibling QObjects such as RendererOwner get to + * delete this surface. QPointer is null by then, and deleting null + * is a no-op. */ + delete m_root.data(); +} + +void PluginUiRenderer::RenderedSurface::clearRoot() +{ + m_nodes.clear(); + if (!m_root) + return; + if (QLayout* old = m_root->layout()) { + QLayoutItem* item; + while ((item = old->takeAt(0)) != nullptr) { + if (QWidget* w = item->widget()) + delete w; + delete item; + } + delete old; + } +} + +QWidget* PluginUiRenderer::RenderedSurface::wrapWithLabel(const QString& label, + QWidget* control) +{ + if (label.isEmpty()) + return control; + auto* row = new QWidget(); + auto* layout = new QHBoxLayout(row); + layout->setContentsMargins(0, 0, 0, 0); + layout->addWidget(new QLabel(label)); + layout->addWidget(control, 1); + return row; +} + +QWidget* PluginUiRenderer::RenderedSurface::renderNode(const QJsonObject& node) +{ + const QString type = node.value(QStringLiteral("type")).toString(); + const QString id = node.value(QStringLiteral("id")).toString(); + const QJsonObject props = node.value(QStringLiteral("props")).toObject(); + + QWidget* widget = nullptr; /* inserted into the parent layout */ + QWidget* indexTarget = nullptr; /* touched by set()/collectValues() */ + EventSink sink = m_sink; + + if (type == QLatin1String("column") || type == QLatin1String("row")) { + auto* container = new QWidget(); + QBoxLayout* layout = + (type == QLatin1String("row")) + ? static_cast(new QHBoxLayout(container)) + : static_cast(new QVBoxLayout(container)); + layout->setContentsMargins(0, 0, 0, 0); + const QJsonArray children = node.value(QStringLiteral("children")).toArray(); + for (const QJsonValue& c : children) { + if (QWidget* cw = renderNode(c.toObject())) + layout->addWidget(cw); + } + widget = indexTarget = container; + } else if (type == QLatin1String("section")) { + auto* group = new QGroupBox(props.value(QStringLiteral("title")).toString()); + auto* layout = new QVBoxLayout(group); + const QJsonArray children = node.value(QStringLiteral("children")).toArray(); + for (const QJsonValue& c : children) { + if (QWidget* cw = renderNode(c.toObject())) + layout->addWidget(cw); + } + widget = indexTarget = group; + } else if (type == QLatin1String("heading")) { + auto* label = new QLabel(props.value(QStringLiteral("text")).toString()); + QFont f = label->font(); + f.setBold(true); + f.setPointSize(f.pointSize() + 2); + label->setFont(f); + label->setWordWrap(true); + widget = indexTarget = label; + } else if (type == QLatin1String("text")) { + auto* label = new QLabel(); + label->setWordWrap(true); + const QString format = + props.value(QStringLiteral("format")).toString(QStringLiteral("plain")); + label->setText(props.value(QStringLiteral("text")).toString()); + if (format == QLatin1String("markdown")) { + label->setTextFormat(Qt::MarkdownText); + label->setTextInteractionFlags(Qt::TextBrowserInteraction); + label->setOpenExternalLinks(false); + QString nodeId = id; + QObject::connect(label, &QLabel::linkActivated, label, + [sink, nodeId](const QString& link) { + if (sink) + sink(nodeId, QStringLiteral("click"), + jsonQuoteString(link)); + }); + } else { + label->setTextFormat(Qt::PlainText); + } + widget = indexTarget = label; + } else if (type == QLatin1String("separator")) { + auto* line = new QFrame(); + line->setFrameShape(QFrame::HLine); + line->setFrameShadow(QFrame::Sunken); + widget = indexTarget = line; + } else if (type == QLatin1String("progress")) { + auto* bar = new QProgressBar(); + const int value = props.value(QStringLiteral("value")).toInt(-1); + if (value < 0) + bar->setRange(0, 0); + else { + bar->setRange(0, 100); + bar->setValue(qBound(0, value, 100)); + } + widget = indexTarget = bar; + } else if (type == QLatin1String("button")) { + auto* btn = new QPushButton(props.value(QStringLiteral("label")).toString()); + btn->setEnabled(props.value(QStringLiteral("enabled")).toBool(true)); + btn->setProperty("mmcoStyle", + props.value(QStringLiteral("style")) + .toString(QStringLiteral("default"))); + QString nodeId = id; + QObject::connect(btn, &QPushButton::clicked, btn, [sink, nodeId]() { + if (sink) + sink(nodeId, QStringLiteral("click"), QString()); + }); + widget = indexTarget = btn; + } else if (type == QLatin1String("toggle")) { + auto* chk = new QCheckBox(props.value(QStringLiteral("label")).toString()); + chk->setChecked(props.value(QStringLiteral("value")).toBool(false)); + chk->setEnabled(props.value(QStringLiteral("enabled")).toBool(true)); + QString nodeId = id; + QObject::connect(chk, &QCheckBox::toggled, chk, [sink, nodeId](bool v) { + if (sink) + sink(nodeId, QStringLiteral("change"), + v ? QStringLiteral("true") : QStringLiteral("false")); + }); + widget = indexTarget = chk; + } else if (type == QLatin1String("text_field")) { + auto* edit = new QLineEdit(props.value(QStringLiteral("value")).toString()); + edit->setPlaceholderText(props.value(QStringLiteral("placeholder")).toString()); + edit->setEnabled(props.value(QStringLiteral("enabled")).toBool(true)); + QString nodeId = id; + QObject::connect(edit, &QLineEdit::editingFinished, edit, + [sink, nodeId, edit]() { + if (sink) + sink(nodeId, QStringLiteral("change"), + jsonQuoteString(edit->text())); + }); + indexTarget = edit; + widget = wrapWithLabel(props.value(QStringLiteral("label")).toString(), edit); + } else if (type == QLatin1String("number_field")) { + auto* spin = new QDoubleSpinBox(); + const int decimals = props.value(QStringLiteral("decimals")).toInt(0); + spin->setDecimals(decimals); + spin->setRange(props.value(QStringLiteral("min")).toDouble(0), + props.value(QStringLiteral("max")).toDouble(100)); + spin->setSingleStep(props.value(QStringLiteral("step")).toDouble(1)); + spin->setValue(props.value(QStringLiteral("value")).toDouble(0)); + spin->setEnabled(props.value(QStringLiteral("enabled")).toBool(true)); + QString nodeId = id; + QObject::connect(spin, QOverload::of(&QDoubleSpinBox::valueChanged), + spin, [sink, nodeId, decimals](double v) { + if (sink) + sink(nodeId, QStringLiteral("change"), + QString::number(v, 'f', decimals)); + }); + indexTarget = spin; + widget = wrapWithLabel(props.value(QStringLiteral("label")).toString(), spin); + } else if (type == QLatin1String("choice")) { + auto* combo = new QComboBox(); + const QJsonArray options = props.value(QStringLiteral("options")).toArray(); + for (const QJsonValue& opt : options) { + if (opt.isObject()) { + const QJsonObject o = opt.toObject(); + combo->addItem(o.value(QStringLiteral("label")).toString(), + o.value(QStringLiteral("id")).toString()); + } else { + const QString s = opt.toString(); + combo->addItem(s, s); + } + } + const int idx = qMax( + 0, combo->findData(props.value(QStringLiteral("value")).toString())); + combo->setCurrentIndex(idx); + combo->setEnabled(props.value(QStringLiteral("enabled")).toBool(true)); + QString nodeId = id; + QObject::connect(combo, QOverload::of(&QComboBox::currentIndexChanged), + combo, [sink, nodeId, combo](int i) { + if (sink) + sink(nodeId, QStringLiteral("change"), + jsonQuoteString(combo->itemData(i).toString())); + }); + indexTarget = combo; + widget = wrapWithLabel(props.value(QStringLiteral("label")).toString(), combo); + } else if (type == QLatin1String("list")) { + auto* tree = new QTreeWidget(); + tree->setRootIsDecorated(false); + tree->setAlternatingRowColors(true); + tree->setSelectionMode(QAbstractItemView::SingleSelection); + const QJsonArray columns = props.value(QStringLiteral("columns")).toArray(); + QStringList headers; + for (const QJsonValue& c : columns) + headers << c.toString(); + tree->setHeaderLabels(headers); + if (!headers.isEmpty()) { + tree->header()->setStretchLastSection(false); + tree->header()->setSectionResizeMode(0, QHeaderView::Stretch); + for (int i = 1; i < headers.size(); ++i) + tree->header()->setSectionResizeMode(i, QHeaderView::ResizeToContents); + } + populateListRows(tree, props.value(QStringLiteral("rows")).toArray()); + QString nodeId = id; + QObject::connect(tree, &QTreeWidget::itemSelectionChanged, tree, + [sink, nodeId, tree]() { + if (!sink) + return; + const auto items = tree->selectedItems(); + const QString rowId = + items.isEmpty() + ? QString() + : items.first()->data(0, Qt::UserRole).toString(); + sink(nodeId, QStringLiteral("select"), + jsonQuoteString(rowId)); + }); + QObject::connect(tree, &QTreeWidget::itemActivated, tree, + [sink, nodeId](QTreeWidgetItem* item, int) { + if (sink && item) + sink(nodeId, QStringLiteral("activate"), + jsonQuoteString(item->data(0, Qt::UserRole).toString())); + }); + widget = indexTarget = tree; + } else if (type == QLatin1String("link")) { + auto* label = new QLabel(); + const QString text = props.value(QStringLiteral("text")).toString(); + const QString href = props.value(QStringLiteral("href")).toString(); + label->setText(QStringLiteral("%2") + .arg(href.toHtmlEscaped(), text.toHtmlEscaped())); + label->setTextFormat(Qt::RichText); + label->setTextInteractionFlags(Qt::TextBrowserInteraction); + label->setOpenExternalLinks(false); + QString nodeId = id; + QObject::connect(label, &QLabel::linkActivated, label, + [sink, nodeId](const QString& link) { + if (sink) + sink(nodeId, QStringLiteral("click"), + jsonQuoteString(link)); + }); + widget = indexTarget = label; + } else { + qWarning().noquote() << "[PluginUiRenderer] Unknown node type:" << type; + return nullptr; + } + + if (widget) + widget->setVisible(props.value(QStringLiteral("visible")).toBool(true)); + if (!id.isEmpty() && indexTarget) { + /* Stable objectName so tests (and any future QSS theming) can + * findChild(id) instead of reaching into m_nodes. */ + indexTarget->setObjectName(id); + m_nodes.insert(id, NodeEntry{indexTarget, type}); + } + return widget; +} + +bool PluginUiRenderer::RenderedSurface::setDocument(const QJsonObject& doc) +{ + /* The page this surface was shown on is gone; a plugin updating it + * afterwards has nothing left to draw into. */ + if (!m_root) + return false; + clearRoot(); + const QJsonObject root = doc.value(QStringLiteral("root")).toObject(); + if (root.isEmpty()) { + qWarning() << "[PluginUiRenderer] document has no \"root\" node"; + new QVBoxLayout(m_root); /* keep m_root layout-valid but empty */ + return false; + } + QWidget* content = renderNode(root); + auto* layout = new QVBoxLayout(m_root); + layout->setContentsMargins(0, 0, 0, 0); + if (content) { + layout->addWidget(content); + return true; + } + qWarning() << "[PluginUiRenderer] failed to render root node"; + return false; +} + +bool PluginUiRenderer::RenderedSurface::setNodeProps(const QString& nodeId, + const QJsonObject& props) +{ + auto it = m_nodes.find(nodeId); + if (it == m_nodes.end() || !it->widget) + return false; + QWidget* w = it->widget; + const QString& type = it->type; + + if (props.contains(QStringLiteral("visible"))) + w->setVisible(props.value(QStringLiteral("visible")).toBool(true)); + if (props.contains(QStringLiteral("enabled"))) + w->setEnabled(props.value(QStringLiteral("enabled")).toBool(true)); + + if (type == QLatin1String("section")) { + if (auto* group = qobject_cast(w)) { + if (props.contains(QStringLiteral("title"))) + group->setTitle(props.value(QStringLiteral("title")).toString()); + } + } else if (type == QLatin1String("heading") || type == QLatin1String("text")) { + if (auto* label = qobject_cast(w)) { + if (props.contains(QStringLiteral("text"))) + label->setText(props.value(QStringLiteral("text")).toString()); + } + } else if (type == QLatin1String("progress")) { + if (auto* bar = qobject_cast(w)) { + if (props.contains(QStringLiteral("value"))) { + const int value = props.value(QStringLiteral("value")).toInt(-1); + if (value < 0) + bar->setRange(0, 0); + else { + bar->setRange(0, 100); + bar->setValue(qBound(0, value, 100)); + } + } + } + } else if (type == QLatin1String("button")) { + if (auto* btn = qobject_cast(w)) { + if (props.contains(QStringLiteral("label"))) + btn->setText(props.value(QStringLiteral("label")).toString()); + if (props.contains(QStringLiteral("style"))) + btn->setProperty("mmcoStyle", + props.value(QStringLiteral("style")).toString()); + } + } else if (type == QLatin1String("toggle")) { + if (auto* chk = qobject_cast(w)) { + if (props.contains(QStringLiteral("label"))) + chk->setText(props.value(QStringLiteral("label")).toString()); + if (props.contains(QStringLiteral("value"))) { + const QSignalBlocker blocker(chk); + chk->setChecked(props.value(QStringLiteral("value")).toBool(false)); + } + } + } else if (type == QLatin1String("text_field")) { + if (auto* edit = qobject_cast(w)) { + if (props.contains(QStringLiteral("value"))) { + const QSignalBlocker blocker(edit); + edit->setText(props.value(QStringLiteral("value")).toString()); + } + if (props.contains(QStringLiteral("placeholder"))) + edit->setPlaceholderText( + props.value(QStringLiteral("placeholder")).toString()); + } + } else if (type == QLatin1String("number_field")) { + if (auto* spin = qobject_cast(w)) { + if (props.contains(QStringLiteral("min"))) + spin->setMinimum(props.value(QStringLiteral("min")).toDouble(0)); + if (props.contains(QStringLiteral("max"))) + spin->setMaximum(props.value(QStringLiteral("max")).toDouble(100)); + if (props.contains(QStringLiteral("value"))) { + const QSignalBlocker blocker(spin); + spin->setValue(props.value(QStringLiteral("value")).toDouble(0)); + } + } + } else if (type == QLatin1String("choice")) { + if (auto* combo = qobject_cast(w)) { + if (props.contains(QStringLiteral("value"))) { + const int idx = combo->findData( + props.value(QStringLiteral("value")).toString()); + if (idx >= 0) { + const QSignalBlocker blocker(combo); + combo->setCurrentIndex(idx); + } + } + } + } + /* "list" rows go through setRows(), not setNodeProps(). */ + return true; +} + +bool PluginUiRenderer::RenderedSurface::setRows(const QString& nodeId, + const QJsonArray& rows) +{ + auto it = m_nodes.find(nodeId); + if (it == m_nodes.end() || it->type != QLatin1String("list")) + return false; + auto* tree = qobject_cast(it->widget); + if (!tree) + return false; + populateListRows(tree, rows); + return true; +} + +QString PluginUiRenderer::RenderedSurface::nodeType(const QString& nodeId) const +{ + auto it = m_nodes.find(nodeId); + return it == m_nodes.end() ? QString() : it->type; +} + +QJsonObject PluginUiRenderer::RenderedSurface::collectValues() const +{ + QJsonObject out; + for (auto it = m_nodes.constBegin(); it != m_nodes.constEnd(); ++it) { + const QString& type = it->type; + QWidget* w = it->widget; + if (type == QLatin1String("toggle")) { + if (auto* chk = qobject_cast(w)) + out.insert(it.key(), chk->isChecked()); + } else if (type == QLatin1String("text_field")) { + if (auto* edit = qobject_cast(w)) + out.insert(it.key(), edit->text()); + } else if (type == QLatin1String("number_field")) { + if (auto* spin = qobject_cast(w)) + out.insert(it.key(), spin->value()); + } else if (type == QLatin1String("choice")) { + if (auto* combo = qobject_cast(w)) + out.insert(it.key(), combo->currentData().toString()); + } + } + return out; +} + +std::unique_ptr +PluginUiRenderer::build(const QString& jsonDoc, EventSink sink) +{ + auto surface = std::make_unique(std::move(sink)); + QJsonParseError err{}; + const QJsonDocument jd = QJsonDocument::fromJson(jsonDoc.toUtf8(), &err); + if (err.error != QJsonParseError::NoError || !jd.isObject()) { + qWarning().noquote() << "[PluginUiRenderer] invalid JSON document:" + << err.errorString(); + return surface; + } + surface->setDocument(jd.object()); + return surface; +} + +bool PluginUiRenderer::buildTrayMenu(QMenu* menu, const QString& jsonDoc, + EventSink sink) +{ + if (!menu) + return false; + menu->clear(); + + QJsonParseError err{}; + const QJsonDocument jd = QJsonDocument::fromJson(jsonDoc.toUtf8(), &err); + if (err.error != QJsonParseError::NoError || !jd.isObject()) { + qWarning().noquote() << "[PluginUiRenderer] invalid tray menu JSON:" + << err.errorString(); + return false; + } + + /* Recursive helper: `section` items become nested QMenus. */ + std::function populate = + [&](QMenu* m, const QJsonArray& arr) { + for (const QJsonValue& iv : arr) { + const QJsonObject item = iv.toObject(); + const QString type = item.value(QStringLiteral("type")).toString(); + const QJsonObject props = item.value(QStringLiteral("props")).toObject(); + if (type == QLatin1String("separator")) { + m->addSeparator(); + } else if (type == QLatin1String("section")) { + QMenu* sub = + m->addMenu(props.value(QStringLiteral("title")).toString()); + populate(sub, item.value(QStringLiteral("children")).toArray()); + } else if (type == QLatin1String("button")) { + const QString id = item.value(QStringLiteral("id")).toString(); + QAction* act = + m->addAction(props.value(QStringLiteral("label")).toString()); + act->setEnabled(props.value(QStringLiteral("enabled")).toBool(true)); + QObject::connect(act, &QAction::triggered, act, + [sink, id]() { + if (sink) + sink(id, QStringLiteral("click"), QString()); + }); + } + } + }; + populate(menu, jd.object().value(QStringLiteral("items")).toArray()); + return true; +} diff --git a/launcher/plugin/PluginUiRenderer.h b/launcher/plugin/PluginUiRenderer.h new file mode 100644 index 00000000..036435c1 --- /dev/null +++ b/launcher/plugin/PluginUiRenderer.h @@ -0,0 +1,141 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +class QWidget; +class QMenu; + +/* + * PluginUiRenderer — turns an "mmco-ui/1" JSON document (the format + * MMCOContext::ui_surface_create / ui_surface_update / ui_modal_run + * take, see PluginAPI.h S33) into a live QWidget tree, MeshMC_logic + * side (it builds real widgets, so it cannot live in the Qt-free SDK). + * + * Fourteen node types are understood: + * Containers (have "children"): column, row, section + * Content leaves: heading, text, separator, + * progress + * Interactive leaves: button, toggle, text_field, + * number_field, choice, list, + * link + * + * Every node may carry a plugin-assigned "id" (used for events and for + * PluginManager::api_ui_surface_set / _set_rows) and a flat "props" + * object. No colour/font/geometry prop exists on any node — the host + * owns all presentation. + * + * A RenderedSurface owns exactly one top-level QWidget (rootWidget()), + * created once and never replaced — setDocument() rebuilds everything + * *inside* it, so a caller that has parented rootWidget() somewhere + * never has to reparent anything again. RenderedSurface does not + * delete rootWidget() itself; whoever parents it into a page/dialog + * owns it the normal Qt way (parent-child cascade on destruction). + */ +class PluginUiRenderer +{ + public: + /* nodeId, event ("click" / "change" / "select" / "activate"), + * value_json (a JSON-encoded value, or "" when not applicable). */ + using EventSink = std::function; + + class RenderedSurface + { + public: + explicit RenderedSurface(EventSink sink); + ~RenderedSurface(); + + RenderedSurface(const RenderedSurface&) = delete; + RenderedSurface& operator=(const RenderedSurface&) = delete; + + /* The single stable top-level widget. Never null after + * construction; never replaced by any of the calls below. */ + QWidget* rootWidget() const + { + return m_root.data(); + } + + /* Parse and (re)build the whole tree from an "mmco-ui/1" + * document. Returns false on malformed JSON / missing root, + * in which case rootWidget() is left empty. */ + bool setDocument(const QJsonObject& doc); + + /* Patch one node's `props` in place (merged over the existing + * props for that node). Returns false if node_id is unknown or + * the node type doesn't understand one of the given keys. */ + bool setNodeProps(const QString& nodeId, const QJsonObject& props); + + /* Replace a `list` node's `rows` array in place, leaving its + * `columns` untouched. Returns false if node_id doesn't name a + * `list` node. */ + bool setRows(const QString& nodeId, const QJsonArray& rows); + + /* Look up the node type for `nodeId` ("button", "toggle", …), + * or an empty string if unknown. Used by ui_modal_run to spot + * which button fired. */ + QString nodeType(const QString& nodeId) const; + + /* Current value of every interactive node (toggle/text_field/ + * number_field/choice), keyed by node id — used to build + * ui_modal_run's "fields" result object. */ + QJsonObject collectValues() const; + + private: + struct NodeEntry { + QWidget* widget = nullptr; /* the interactive/leaf widget itself */ + QString type; + }; + + QWidget* renderNode(const QJsonObject& node); + QWidget* wrapWithLabel(const QString& label, QWidget* control); + void clearRoot(); + + EventSink m_sink; + /* Stable identity; content rebuilt in place. A QPointer because + * whatever it gets parented under may delete it first: when a + * settings group box is torn down it deletes its child widgets, + * this root among them, before the RendererOwner sibling that + * owns this surface. */ + QPointer m_root; + QHash m_nodes; + }; + + /* Parse `jsonDoc` (an "mmco-ui/1" document) and build a live + * surface. Never returns null — a malformed document renders as an + * empty surface (setDocument()'s return value already logged the + * problem via qWarning). */ + static std::unique_ptr build(const QString& jsonDoc, + EventSink sink); + + /* Parse a tray-menu document (small tree of button/separator/section + * nodes — section = submenu) and populate `menu` with QActions wired + * to `sink(nodeId, "click", "")`. Returns false on malformed JSON. */ + static bool buildTrayMenu(QMenu* menu, const QString& jsonDoc, + EventSink sink); +}; diff --git a/launcher/plugin/PluginUiRenderer_test.cpp b/launcher/plugin/PluginUiRenderer_test.cpp new file mode 100644 index 00000000..c995aeb6 --- /dev/null +++ b/launcher/plugin/PluginUiRenderer_test.cpp @@ -0,0 +1,329 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "plugin/PluginUiRenderer.h" + +namespace +{ + /* A doc exercising every one of the 14 node types, nested so + * containers (column/row/section) get covered too. */ + const char* kFullDoc = + "{\n" + " \"type\": \"mmco-ui/1\",\n" + " \"root\": {\n" + " \"type\": \"column\", \"id\": \"root\",\n" + " \"children\": [\n" + " { \"type\": \"heading\", \"id\": \"h1\", \"props\": { \"text\": \"Heading\" } },\n" + " { \"type\": \"text\", \"id\": \"t1\", \"props\": { \"text\": \"Plain text\" } },\n" + " { \"type\": \"text\", \"id\": \"t2\",\n" + " \"props\": { \"text\": \"See [link](wiki:slug)\", \"format\": \"markdown\" } },\n" + " { \"type\": \"separator\", \"id\": \"sep1\" },\n" + " { \"type\": \"progress\", \"id\": \"p1\", \"props\": { \"value\": 42 } },\n" + " { \"type\": \"button\", \"id\": \"btn1\",\n" + " \"props\": { \"label\": \"Click Me\", \"enabled\": true } },\n" + " { \"type\": \"toggle\", \"id\": \"tg1\",\n" + " \"props\": { \"label\": \"Enable thing\", \"value\": false } },\n" + " { \"type\": \"text_field\", \"id\": \"tf1\",\n" + " \"props\": { \"label\": \"Name\", \"value\": \"abc\" } },\n" + " { \"type\": \"number_field\", \"id\": \"nf1\",\n" + " \"props\": { \"label\": \"Count\", \"value\": 3, \"min\": 0, \"max\": 10, \"decimals\": 0 } },\n" + " { \"type\": \"choice\", \"id\": \"ch1\",\n" + " \"props\": { \"label\": \"Pick\", \"options\": [\"a\", \"b\", \"c\"], \"value\": \"b\" } },\n" + " { \"type\": \"list\", \"id\": \"lst1\",\n" + " \"props\": { \"columns\": [\"Col1\", \"Col2\"],\n" + " \"rows\": [ { \"id\": \"r1\", \"cells\": [\"A\", \"B\"] } ] } },\n" + " { \"type\": \"link\", \"id\": \"lnk1\",\n" + " \"props\": { \"text\": \"Open\", \"href\": \"https://example.com\" } },\n" + " { \"type\": \"section\", \"id\": \"sec1\", \"props\": { \"title\": \"Group\" },\n" + " \"children\": [\n" + " { \"type\": \"row\", \"id\": \"row1\",\n" + " \"children\": [\n" + " { \"type\": \"button\", \"id\": \"btn2\", \"props\": { \"label\": \"Inner\" } }\n" + " ] }\n" + " ] }\n" + " ]\n" + " }\n" + "}\n"; + + struct Event { + QString nodeId; + QString event; + QString valueJson; + }; +} // namespace + +class PluginUiRendererTest : public QObject +{ + Q_OBJECT + + private slots: + /* Every node type produces the expected concrete widget class, with + * props applied (label text, checked state, value, enabled, list + * columns/rows). */ + void test_rendersEveryNodeType() + { + QVector events; + auto sink = [&events](const QString& id, const QString& ev, + const QString& val) { events.append({id, ev, val}); }; + + auto surface = PluginUiRenderer::build(QString::fromUtf8(kFullDoc), sink); + QVERIFY(surface != nullptr); + QWidget* root = surface->rootWidget(); + QVERIFY(root != nullptr); + + auto* heading = root->findChild("h1"); + QVERIFY(heading); + QCOMPARE(heading->text(), QStringLiteral("Heading")); + + auto* text1 = root->findChild("t1"); + QVERIFY(text1); + QCOMPARE(text1->textFormat(), Qt::PlainText); + + auto* text2 = root->findChild("t2"); + QVERIFY(text2); + QCOMPARE(text2->textFormat(), Qt::MarkdownText); + + QVERIFY(root->findChild("sep1")); + + auto* progress = root->findChild("p1"); + QVERIFY(progress); + QCOMPARE(progress->value(), 42); + + auto* button = root->findChild("btn1"); + QVERIFY(button); + QCOMPARE(button->text(), QStringLiteral("Click Me")); + QVERIFY(button->isEnabled()); + + auto* toggle = root->findChild("tg1"); + QVERIFY(toggle); + QCOMPARE(toggle->text(), QStringLiteral("Enable thing")); + QVERIFY(!toggle->isChecked()); + + auto* textField = root->findChild("tf1"); + QVERIFY(textField); + QCOMPARE(textField->text(), QStringLiteral("abc")); + + auto* numberField = root->findChild("nf1"); + QVERIFY(numberField); + QCOMPARE(numberField->value(), 3.0); + + auto* choice = root->findChild("ch1"); + QVERIFY(choice); + QCOMPARE(choice->currentData().toString(), QStringLiteral("b")); + + auto* list = root->findChild("lst1"); + QVERIFY(list); + QCOMPARE(list->topLevelItemCount(), 1); + QCOMPARE(list->topLevelItem(0)->text(0), QStringLiteral("A")); + QCOMPARE(list->topLevelItem(0)->text(1), QStringLiteral("B")); + + QVERIFY(root->findChild("lnk1")); + QVERIFY(root->findChild("sec1")); + QVERIFY(root->findChild("btn2")); + + QCOMPARE(surface->nodeType("tg1"), QStringLiteral("toggle")); + QCOMPARE(surface->nodeType("nosuchnode"), QString()); + } + + /* ui_surface_set (PluginUiRenderer::setNodeProps) patches a single + * node's props in place without disturbing the rest of the tree. */ + void test_setNodePropsPatchesInPlace() + { + auto surface = PluginUiRenderer::build(QString::fromUtf8(kFullDoc), + PluginUiRenderer::EventSink()); + auto* toggle = surface->rootWidget()->findChild("tg1"); + QVERIFY(toggle); + QVERIFY(!toggle->isChecked()); + + QJsonObject patch; + patch["value"] = true; + QVERIFY(surface->setNodeProps("tg1", patch)); + QVERIFY(toggle->isChecked()); + + /* The button next to it is untouched. */ + auto* button = surface->rootWidget()->findChild("btn1"); + QCOMPARE(button->text(), QStringLiteral("Click Me")); + + QJsonObject btnPatch; + btnPatch["enabled"] = false; + QVERIFY(surface->setNodeProps("btn1", btnPatch)); + QVERIFY(!button->isEnabled()); + + /* Unknown node id fails cleanly. */ + QVERIFY(!surface->setNodeProps("does-not-exist", patch)); + } + + /* The widget a surface is shown in may be torn down before the + * surface itself -- PluginManager's RendererOwner is a sibling QObject + * of the root inside a settings group box, and the group box deletes + * its child widgets first. Destroying the surface afterwards must not + * delete the root a second time, and updating it must fail cleanly. */ + void test_surfaceOutlivesItsParentWidget() + { + auto surface = PluginUiRenderer::build(QString::fromUtf8(kFullDoc), + PluginUiRenderer::EventSink()); + auto* parent = new QWidget(); + surface->rootWidget()->setParent(parent); + delete parent; + + QVERIFY(!surface->rootWidget()); + QVERIFY(!surface->setDocument(QJsonDocument::fromJson(kFullDoc).object())); + surface.reset(); + } + + /* ui_surface_set_rows (PluginUiRenderer::setRows) replaces a list's + * rows without touching its columns. */ + void test_setRowsReplacesListContent() + { + auto surface = PluginUiRenderer::build(QString::fromUtf8(kFullDoc), + PluginUiRenderer::EventSink()); + auto* list = surface->rootWidget()->findChild("lst1"); + QVERIFY(list); + QCOMPARE(list->topLevelItemCount(), 1); + QCOMPARE(list->headerItem()->text(0), QStringLiteral("Col1")); + + QJsonArray rows; + QJsonObject r1; + r1["id"] = "x"; + r1["cells"] = QJsonArray{"X1", "X2"}; + QJsonObject r2; + r2["id"] = "y"; + r2["cells"] = QJsonArray{"Y1", "Y2"}; + rows.append(r1); + rows.append(r2); + + QVERIFY(surface->setRows("lst1", rows)); + QCOMPARE(list->topLevelItemCount(), 2); + QCOMPARE(list->topLevelItem(1)->text(0), QStringLiteral("Y1")); + /* Columns untouched by setRows(). */ + QCOMPARE(list->headerItem()->text(0), QStringLiteral("Col1")); + + /* Wrong node type refuses. */ + QVERIFY(!surface->setRows("btn1", rows)); + } + + /* A button click and a toggle flip each fire exactly the event the + * ABI promises: event="click"/"change", node_id matching the node, + * value_json carrying the new value for "change". */ + void test_buttonAndToggleEmitExpectedEvents() + { + QVector events; + auto sink = [&events](const QString& id, const QString& ev, + const QString& val) { events.append({id, ev, val}); }; + auto surface = PluginUiRenderer::build(QString::fromUtf8(kFullDoc), sink); + + auto* button = surface->rootWidget()->findChild("btn1"); + QVERIFY(button); + button->click(); + + QVERIFY(!events.isEmpty()); + QCOMPARE(events.last().nodeId, QStringLiteral("btn1")); + QCOMPARE(events.last().event, QStringLiteral("click")); + + events.clear(); + auto* toggle = surface->rootWidget()->findChild("tg1"); + QVERIFY(toggle); + toggle->click(); /* flips false -> true and emits toggled(true) */ + + QCOMPARE(events.size(), 1); + QCOMPARE(events.first().nodeId, QStringLiteral("tg1")); + QCOMPARE(events.first().event, QStringLiteral("change")); + QCOMPARE(events.first().valueJson, QStringLiteral("true")); + } + + /* The declarative tray-menu doc (button/separator/section) builds a + * real QMenu and wires clicks through the same event sink shape. */ + void test_buildTrayMenu() + { + const char* menuDoc = + "{\n" + " \"type\": \"mmco-tray-menu/1\",\n" + " \"items\": [\n" + " { \"type\": \"button\", \"id\": \"open\", \"props\": { \"label\": \"Open MeshMC\" } },\n" + " { \"type\": \"separator\" },\n" + " { \"type\": \"section\", \"id\": \"launch\", \"props\": { \"title\": \"Launch instance\" },\n" + " \"children\": [\n" + " { \"type\": \"button\", \"id\": \"inst-1\", \"props\": { \"label\": \"My Instance\" } }\n" + " ] },\n" + " { \"type\": \"separator\" },\n" + " { \"type\": \"button\", \"id\": \"quit\", \"props\": { \"label\": \"Quit MeshMC\" } }\n" + " ]\n" + "}\n"; + QVector events; + auto sink = [&events](const QString& id, const QString& ev, + const QString& val) { events.append({id, ev, val}); }; + + QMenu menu; + QVERIFY(PluginUiRenderer::buildTrayMenu(&menu, QString::fromUtf8(menuDoc), sink)); + + /* open, separator, "Launch instance" submenu, separator, quit. */ + const auto actions = menu.actions(); + QCOMPARE(actions.size(), 5); + QCOMPARE(actions.at(0)->text(), QStringLiteral("Open MeshMC")); + QVERIFY(actions.at(1)->isSeparator()); + QVERIFY(actions.at(2)->menu() != nullptr); + QCOMPARE(actions.at(2)->menu()->title(), QStringLiteral("Launch instance")); + QVERIFY(actions.at(3)->isSeparator()); + QCOMPARE(actions.at(4)->text(), QStringLiteral("Quit MeshMC")); + + actions.at(0)->trigger(); + QCOMPARE(events.size(), 1); + QCOMPARE(events.first().nodeId, QStringLiteral("open")); + QCOMPARE(events.first().event, QStringLiteral("click")); + + /* The nested submenu's own action fires with its own id. */ + actions.at(2)->menu()->actions().first()->trigger(); + QCOMPARE(events.size(), 2); + QCOMPARE(events.last().nodeId, QStringLiteral("inst-1")); + } +}; + +int main(int argc, char* argv[]) +{ + /* PluginUiRenderer builds real QWidgets, so this test needs a full + * QApplication (not QGuiApplication). Default to the offscreen + * platform plugin when the environment hasn't already picked one, + * so the binary also runs standalone outside `ctest`. */ + if (qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM")) + qputenv("QT_QPA_PLATFORM", QByteArrayLiteral("offscreen")); + + QApplication app(argc, argv); + PluginUiRendererTest testCase; + return QTest::qExec(&testCase, argc, argv); +} + +#include "PluginUiRenderer_test.moc" diff --git a/launcher/plugin/plugins/DesktopNotifier/CMakeLists.txt b/launcher/plugin/plugins/DesktopNotifier/CMakeLists.txt index 74916361..794cd550 100644 --- a/launcher/plugin/plugins/DesktopNotifier/CMakeLists.txt +++ b/launcher/plugin/plugins/DesktopNotifier/CMakeLists.txt @@ -14,7 +14,7 @@ if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) set(MeshMC_QT_VERSION_MAJOR "6" CACHE STRING "Major Qt version to build against (5 or 6)") set(QT_VERSION_MAJOR "${MeshMC_QT_VERSION_MAJOR}") - find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Core Widgets Gui Network) + find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Core Gui Network) find_package(MeshMC_SDK REQUIRED) set(MESHMC_PLUGIN_STAGING_DIR "${CMAKE_BINARY_DIR}/mmcmodules" CACHE PATH diff --git a/launcher/plugin/plugins/DiscordRPC/CMakeLists.txt b/launcher/plugin/plugins/DiscordRPC/CMakeLists.txt index 5c978292..bdabcfa1 100644 --- a/launcher/plugin/plugins/DiscordRPC/CMakeLists.txt +++ b/launcher/plugin/plugins/DiscordRPC/CMakeLists.txt @@ -14,7 +14,7 @@ if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) set(MeshMC_QT_VERSION_MAJOR "6" CACHE STRING "Major Qt version to build against (5 or 6)") set(QT_VERSION_MAJOR "${MeshMC_QT_VERSION_MAJOR}") - find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Core Widgets Gui Network) + find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Core Gui Network) find_package(MeshMC_SDK REQUIRED) set(MESHMC_PLUGIN_STAGING_DIR "${CMAKE_BINARY_DIR}/mmcmodules" CACHE PATH diff --git a/launcher/plugin/plugins/GitVersioning/CMakeLists.txt b/launcher/plugin/plugins/GitVersioning/CMakeLists.txt index 711fe248..6845991c 100644 --- a/launcher/plugin/plugins/GitVersioning/CMakeLists.txt +++ b/launcher/plugin/plugins/GitVersioning/CMakeLists.txt @@ -14,7 +14,7 @@ if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) set(MeshMC_QT_VERSION_MAJOR "6" CACHE STRING "Major Qt version to build against (5 or 6)") set(QT_VERSION_MAJOR "${MeshMC_QT_VERSION_MAJOR}") - find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Core Widgets Gui Network) + find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Core Gui Network) find_package(MeshMC_SDK REQUIRED) set(MESHMC_PLUGIN_STAGING_DIR "${CMAKE_BINARY_DIR}/mmcmodules" CACHE PATH diff --git a/launcher/plugin/plugins/GitVersioning/GitVersioningPage.cpp b/launcher/plugin/plugins/GitVersioning/GitVersioningPage.cpp index b673c138..65e7fa14 100644 --- a/launcher/plugin/plugins/GitVersioning/GitVersioningPage.cpp +++ b/launcher/plugin/plugins/GitVersioning/GitVersioningPage.cpp @@ -5,6 +5,8 @@ namespace { + constexpr int kModalResultBufSize = 4096; + QString humanSize(qint64 bytes) { if (bytes < 1024) @@ -18,251 +20,433 @@ namespace v /= 1024.0; return QObject::tr("%1 GiB").arg(QString::number(v, 'f', 2)); } -} // namespace -GitVersioningPage::GitVersioningPage(MMCOContext* ctx, - const QString& instanceId, - const QString& instanceRoot, - QWidget* parent) - : QWidget(parent), m_ctx(ctx), m_instanceId(instanceId), - m_instanceRoot(instanceRoot), m_repo(instanceId, instanceRoot) -{ - buildUi(); - reloadHistory(); -} + /* Inverse of PluginUiRenderer's jsonQuoteString: unwraps a bare JSON + * scalar (as delivered in MMCOUiEventCallback's value_json for + * "select"/"activate" events) back into a plain QString. */ + QString jsonStringValue(const QString& valueJson) + { + if (valueJson.isEmpty()) + return QString(); + const QByteArray wrapped = "[" + valueJson.toUtf8() + "]"; + QJsonParseError err{}; + const QJsonDocument jd = QJsonDocument::fromJson(wrapped, &err); + if (err.error != QJsonParseError::NoError || !jd.isArray() || + jd.array().isEmpty()) + return QString(); + return jd.array().first().toString(); + } -bool GitVersioningPage::confirm(const QString& title, - const QString& message) const -{ - /* Prefer the host's confirm dialog (S-tier UI API). It returns 1 - * on confirm, 0 on cancel. The host strips rich-text, so pass a - * plain-text message here. */ - if (m_ctx && m_ctx->ui_confirm_dialog) { - return m_ctx->ui_confirm_dialog(m_ctx->module_handle, title.toUtf8(), - message.toUtf8()) != 0; + /* Shared by the Snapshot-message and Tag-name prompts: a single + * text_field plus an OK/Cancel button row, run through ui_modal_run. + * Replaces the raw QInputDialog::getText calls the QWidget-era page + * used to make directly — the page has no QWidget of its own to + * parent a dialog to any more. */ + QByteArray buildTextPromptDoc(const QString& fieldId, const QString& label, + const QString& defaultValue, + const QString& okId, const QString& okLabel) + { + const QJsonObject field{ + {"type", "text_field"}, + {"id", fieldId}, + {"props", QJsonObject{{"label", label}, {"value", defaultValue}}}}; + const QJsonArray buttons{ + QJsonObject{{"type", "button"}, + {"id", okId}, + {"props", QJsonObject{{"label", okLabel}}}}, + QJsonObject{{"type", "button"}, + {"id", "cancel"}, + {"props", QJsonObject{{"label", QObject::tr("Cancel")}}}}}; + const QJsonObject buttonRow{ + {"type", "row"}, {"id", "actions"}, {"children", buttons}}; + const QJsonArray children{field, buttonRow}; + const QJsonObject root{ + {"type", "column"}, {"id", "root"}, {"children", children}}; + const QJsonObject doc{{"type", "mmco-ui/1"}, {"root", root}}; + return QJsonDocument(doc).toJson(QJsonDocument::Compact); } - return QMessageBox::question(const_cast(this), title, - message) == QMessageBox::Yes; +} // namespace + +GitVersioningPageController::GitVersioningPageController(MMCOContext* ctx, + QString instanceId, + QString instanceRoot) + : m_ctx(ctx), m_instanceId(std::move(instanceId)), + m_instanceRoot(std::move(instanceRoot)), + m_repo(m_instanceId, m_instanceRoot) +{ } -QIcon GitVersioningPage::icon() const +bool GitVersioningPageController::confirm(const QString& title, + const QString& message) const { - /* The launcher's icon theme is reachable through QIcon::fromTheme - * once Qt has been initialised; the host installs the MeshMC theme - * search paths during Application::init() so plugin code finds the - * same icon set the launcher does. */ - return QIcon::fromTheme(QStringLiteral("git-scm")); + if (!m_ctx || !m_ctx->ui_confirm_dialog) + return false; + return m_ctx->ui_confirm_dialog(m_ctx->module_handle, title.toUtf8().constData(), + message.toUtf8().constData()) != 0; } -void GitVersioningPage::buildUi() +void GitVersioningPageController::notify(int type, const QString& title, + const QString& message) const { - auto* layout = new QVBoxLayout(this); - - m_statusLabel = new QLabel(this); - m_statusLabel->setWordWrap(true); - layout->addWidget(m_statusLabel); - - m_tree = new QTreeWidget(this); - m_tree->setHeaderLabels({tr("When"), tr("Commit"), tr("Subject"), - tr("Files"), tr("+"), tr("-")}); - m_tree->setRootIsDecorated(false); - m_tree->setSelectionMode(QAbstractItemView::SingleSelection); - m_tree->setSelectionBehavior(QAbstractItemView::SelectRows); - m_tree->setAlternatingRowColors(true); - m_tree->header()->setSectionResizeMode(QHeaderView::ResizeToContents); - connect(m_tree, &QTreeWidget::itemSelectionChanged, this, - &GitVersioningPage::onSelectionChanged); - layout->addWidget(m_tree, /*stretch=*/1); - - auto* btnRow = new QHBoxLayout(); - - m_commitBtn = new QPushButton(tr("Snapshot now"), this); - m_commitBtn->setToolTip(tr("Commit the current state of the instance")); - connect(m_commitBtn, &QPushButton::clicked, this, - &GitVersioningPage::onCommitClicked); - btnRow->addWidget(m_commitBtn); - - m_restoreBtn = new QPushButton(tr("Restore selected"), this); - m_restoreBtn->setEnabled(false); - connect(m_restoreBtn, &QPushButton::clicked, this, - &GitVersioningPage::onRestoreClicked); - btnRow->addWidget(m_restoreBtn); - - m_tagBtn = new QPushButton(tr("Tag…"), this); - m_tagBtn->setEnabled(false); - connect(m_tagBtn, &QPushButton::clicked, this, - &GitVersioningPage::onTagClicked); - btnRow->addWidget(m_tagBtn); - - m_dropBtn = new QPushButton(tr("Drop last commit"), this); - m_dropBtn->setToolTip(tr("Hard-reset HEAD by one commit. The dropped " - "commit is unreachable but reachable again via " - "the reflog for ~30 days.")); - connect(m_dropBtn, &QPushButton::clicked, this, - &GitVersioningPage::onDropClicked); - btnRow->addWidget(m_dropBtn); - - btnRow->addStretch(); - - m_refreshBtn = new QPushButton(tr("Refresh"), this); - connect(m_refreshBtn, &QPushButton::clicked, this, - &GitVersioningPage::onRefresh); - btnRow->addWidget(m_refreshBtn); - - layout->addLayout(btnRow); + if (!m_ctx || !m_ctx->ui_show_message) + return; + m_ctx->ui_show_message(m_ctx->module_handle, type, title.toUtf8().constData(), + message.toUtf8().constData()); } -void GitVersioningPage::reloadHistory() +QString GitVersioningPageController::buildStatusText() const { - auto st = m_repo.status(); if (!GitRepo::gitAvailable()) { - m_statusLabel->setText(tr("git is not installed on your system. " - "Install Git and reopen this page.")); - m_commitBtn->setEnabled(false); - m_restoreBtn->setEnabled(false); - m_tagBtn->setEnabled(false); - m_dropBtn->setEnabled(false); - return; + return QObject::tr( + "**git** is not installed on your system. Install Git and reopen " + "this page."); } + auto st = m_repo.status(); if (!st.initialized) { - m_statusLabel->setText( - tr("No version history yet. Snapshot now to start tracking " - "changes to this instance.")); + return QObject::tr( + "No version history yet. Snapshot now to start tracking changes " + "to this instance."); + } + + QStringList parts; + parts << QObject::tr("HEAD: **%1**").arg(st.head); + if (st.dirty) { + parts << QObject::tr("Modified: %1, Deleted: %2, Untracked: %3") + .arg(st.modifiedCount) + .arg(st.deletedCount) + .arg(st.untrackedCount); } else { - QStringList parts; - parts << tr("HEAD: %1").arg(st.head); - if (st.dirty) { - parts << tr("Modified: %1, Deleted: %2, Untracked: %3") - .arg(st.modifiedCount) - .arg(st.deletedCount) - .arg(st.untrackedCount); - } else { - parts << tr("Clean working tree"); - } - parts << tr("git %1").arg(GitRepo::gitVersion()); - m_statusLabel->setText(parts.join(QStringLiteral(" — "))); + parts << QObject::tr("Clean working tree"); } + parts << QObject::tr("git %1").arg(GitRepo::gitVersion()); + return parts.join(QStringLiteral(" — ")); +} - m_commits = m_repo.log(); - m_tree->clear(); +QJsonArray GitVersioningPageController::buildRows() const +{ + QJsonArray rows; // Match the BackupSystem plugin's fixed timestamp format — sortable // and unambiguous across locales. Qt6 removed // Qt::DefaultLocaleShortDate so an explicit format string is the // most portable choice anyway. - for (auto& c : m_commits) { - m_repo.fillCommitStats(c); - auto* item = new QTreeWidgetItem(m_tree); - item->setText(0, - c.when.toString(QStringLiteral("yyyy-MM-dd HH:mm:ss"))); - item->setText(1, c.sha); + for (const auto& c : m_commits) { QString subject = c.subject; if (c.isPreLaunch) subject = QStringLiteral("⚡ ") + subject; - item->setText(2, subject); - item->setText(3, QString::number(c.filesChanged)); - item->setText(4, humanSize(c.sizeAdded)); - item->setText(5, humanSize(c.sizeRemoved)); - item->setData(0, Qt::UserRole, c.fullSha); + const QJsonArray cells{ + c.when.toString(QStringLiteral("yyyy-MM-dd HH:mm:ss")), c.sha, + subject, QString::number(c.filesChanged), humanSize(c.sizeAdded), + humanSize(c.sizeRemoved)}; + rows.append(QJsonObject{{"id", c.fullSha}, {"cells", cells}}); + } + return rows; +} + +QJsonObject GitVersioningPageController::buildDocument() const +{ + const bool gitOk = GitRepo::gitAvailable(); + + const QJsonObject statusNode{ + {"type", "text"}, + {"id", "status"}, + {"props", + QJsonObject{{"format", "markdown"}, {"text", buildStatusText()}}}}; + + const QJsonObject listNode{ + {"type", "list"}, + {"id", "commits"}, + {"props", + QJsonObject{ + {"columns", QJsonArray{QObject::tr("When"), QObject::tr("Commit"), + QObject::tr("Subject"), QObject::tr("Files"), + QObject::tr("+"), QObject::tr("-")}}, + {"rows", gitOk ? buildRows() : QJsonArray{}}}}}; + + const QJsonArray buttons{ + QJsonObject{{"type", "button"}, + {"id", "snapshot"}, + {"props", QJsonObject{{"label", QObject::tr("Snapshot now")}, + {"enabled", gitOk}}}}, + QJsonObject{{"type", "button"}, + {"id", "restore"}, + {"props", QJsonObject{{"label", QObject::tr("Restore selected")}, + {"enabled", false}}}}, + QJsonObject{{"type", "button"}, + {"id", "tag"}, + {"props", QJsonObject{{"label", QObject::tr("Tag…")}, + {"enabled", false}}}}, + QJsonObject{{"type", "button"}, + {"id", "drop"}, + {"props", QJsonObject{{"label", QObject::tr("Drop last commit")}, + {"enabled", gitOk}}}}, + QJsonObject{{"type", "button"}, + {"id", "refresh"}, + {"props", QJsonObject{{"label", QObject::tr("Refresh")}}}}}; + const QJsonObject buttonRow{ + {"type", "row"}, {"id", "buttons"}, {"children", buttons}}; + + const QJsonArray rootChildren{statusNode, listNode, buttonRow}; + const QJsonObject root{ + {"type", "column"}, {"id", "root"}, {"children", rootChildren}}; + return QJsonObject{{"type", "mmco-ui/1"}, {"root", root}}; +} + +void GitVersioningPageController::setStatusText(const QString& text) const +{ + if (!m_ctx || !m_surface) + return; + const QJsonObject patch{{"text", text}}; + const QByteArray json = QJsonDocument(patch).toJson(QJsonDocument::Compact); + m_ctx->ui_surface_set(m_ctx->module_handle, m_surface, "status", + json.constData()); +} + +void GitVersioningPageController::pushRows() const +{ + if (!m_ctx || !m_surface) + return; + const QByteArray json = + QJsonDocument(buildRows()).toJson(QJsonDocument::Compact); + m_ctx->ui_surface_set_rows(m_ctx->module_handle, m_surface, "commits", + json.constData()); +} + +void GitVersioningPageController::setNodeEnabled(const QString& nodeId, + bool enabled) const +{ + if (!m_ctx || !m_surface) + return; + const QJsonObject patch{{"enabled", enabled}}; + const QByteArray json = QJsonDocument(patch).toJson(QJsonDocument::Compact); + m_ctx->ui_surface_set(m_ctx->module_handle, m_surface, + nodeId.toUtf8().constData(), json.constData()); +} + +void GitVersioningPageController::createSurface() +{ + if (!m_ctx || m_surface) + return; + + if (GitRepo::gitAvailable()) { + m_commits = m_repo.log(); + for (auto& c : m_commits) + m_repo.fillCommitStats(c); + } else { + m_commits.clear(); } - onSelectionChanged(); + const QByteArray json = + QJsonDocument(buildDocument()).toJson(QJsonDocument::Compact); + m_surface = m_ctx->ui_surface_create( + m_ctx->module_handle, MMCO_UI_ANCHOR_INSTANCE_PAGE, + m_instanceId.toUtf8().constData(), + QObject::tr("Version History").toUtf8().constData(), "git-scm", + json.constData(), &GitVersioningPageController::eventTrampoline, this); +} + +void GitVersioningPageController::destroySurface() +{ + if (!m_ctx || !m_surface) + return; + m_ctx->ui_surface_destroy(m_ctx->module_handle, m_surface); + m_surface = nullptr; +} + +void GitVersioningPageController::reloadHistory() +{ + if (!m_ctx || !m_surface) + return; + + setStatusText(buildStatusText()); + + const bool gitOk = GitRepo::gitAvailable(); + if (!gitOk) { + setNodeEnabled(QStringLiteral("snapshot"), false); + setNodeEnabled(QStringLiteral("restore"), false); + setNodeEnabled(QStringLiteral("tag"), false); + setNodeEnabled(QStringLiteral("drop"), false); + return; + } + + m_commits = m_repo.log(); + for (auto& c : m_commits) + m_repo.fillCommitStats(c); + pushRows(); + + /* The row set was just replaced wholesale, so — same as the old + * QTreeWidget::clear() used to do — nothing is selected any more. */ + m_selectedSha.clear(); + setNodeEnabled(QStringLiteral("snapshot"), true); + setNodeEnabled(QStringLiteral("restore"), false); + setNodeEnabled(QStringLiteral("tag"), false); + setNodeEnabled(QStringLiteral("drop"), true); } -GitCommit GitVersioningPage::selectedCommit() const +GitCommit GitVersioningPageController::selectedCommit() const { - auto items = m_tree->selectedItems(); - if (items.isEmpty()) + if (m_selectedSha.isEmpty()) return {}; - QString sha = items.first()->data(0, Qt::UserRole).toString(); for (const auto& c : m_commits) - if (c.fullSha == sha) + if (c.fullSha == m_selectedSha) return c; return {}; } -void GitVersioningPage::onSelectionChanged() +void GitVersioningPageController::onSelectionChanged(const QString& rowId) { - bool has = !m_tree->selectedItems().isEmpty(); - m_restoreBtn->setEnabled(has); - m_tagBtn->setEnabled(has); + m_selectedSha = rowId; + const bool has = !rowId.isEmpty(); + setNodeEnabled(QStringLiteral("restore"), has); + setNodeEnabled(QStringLiteral("tag"), has); } -void GitVersioningPage::onCommitClicked() +void GitVersioningPageController::onSnapshotClicked() { - bool ok = false; - QString msg = QInputDialog::getText( - this, tr("Snapshot"), - tr("Describe the changes you're snapshotting (leave blank for an " - "auto-generated message):"), - QLineEdit::Normal, - tr("Manual snapshot %1") + if (!m_ctx) + return; + + const QString defaultMsg = + QObject::tr("Manual snapshot %1") .arg(QDateTime::currentDateTime().toString( - QStringLiteral("yyyy-MM-dd HH:mm"))), - &ok); - if (!ok) + QStringLiteral("yyyy-MM-dd HH:mm"))); + const QByteArray doc = buildTextPromptDoc( + QStringLiteral("message"), + QObject::tr("Describe the changes you're snapshotting:"), defaultMsg, + QStringLiteral("snapshot"), QObject::tr("Snapshot")); + + char resultBuf[kModalResultBufSize]; + const int rc = m_ctx->ui_modal_run( + m_ctx->module_handle, QObject::tr("Snapshot").toUtf8().constData(), + doc.constData(), resultBuf, sizeof(resultBuf)); + if (rc != 0) + return; /* cancelled / dialog closed */ + + QJsonParseError err{}; + const QJsonDocument jd = QJsonDocument::fromJson(QByteArray(resultBuf), &err); + if (err.error != QJsonParseError::NoError || !jd.isObject()) + return; + const QJsonObject result = jd.object(); + if (result.value(QStringLiteral("button")).toString() != + QLatin1String("snapshot")) return; - QString err; - QString sha = m_repo.commit(msg, /*isPreLaunch=*/false, &err); - if (sha.isEmpty() && !err.isEmpty()) { - QMessageBox::warning(this, tr("Snapshot failed"), err); + const QString message = result.value(QStringLiteral("fields")) + .toObject() + .value(QStringLiteral("message")) + .toString(); + + QString errMsg; + QString sha = m_repo.commit(message, /*isPreLaunch=*/false, &errMsg); + if (sha.isEmpty() && !errMsg.isEmpty()) { + notify(1, QObject::tr("Snapshot failed"), errMsg); } else if (sha.isEmpty()) { - QMessageBox::information( - this, tr("Snapshot"), - tr("Nothing to commit — the working tree was clean.")); + notify(0, QObject::tr("Snapshot"), + QObject::tr("Nothing to commit — the working tree was clean.")); } reloadHistory(); } -void GitVersioningPage::onRestoreClicked() +void GitVersioningPageController::onRestoreClicked() { - auto c = selectedCommit(); + GitCommit c = selectedCommit(); if (c.fullSha.isEmpty()) return; - if (!confirm(tr("Restore?"), - tr("Restore the instance to commit %1 (%2)?\n\n" - "An auto-snapshot of the current state is taken first, " - "so this can be undone by restoring the previous " - "snapshot.") - .arg(c.sha, c.subject))) + if (!confirm(QObject::tr("Restore?"), + QObject::tr("Restore the instance to commit %1 (%2)?\n\n" + "An auto-snapshot of the current state is taken " + "first, so this can be undone by restoring the " + "previous snapshot.") + .arg(c.sha, c.subject))) return; - QString err; - if (!m_repo.restore(c.fullSha, &err)) - QMessageBox::warning(this, tr("Restore failed"), err); + QString errMsg; + if (!m_repo.restore(c.fullSha, &errMsg)) + notify(1, QObject::tr("Restore failed"), errMsg); reloadHistory(); } -void GitVersioningPage::onTagClicked() +void GitVersioningPageController::onTagClicked() { - auto c = selectedCommit(); + if (!m_ctx) + return; + GitCommit c = selectedCommit(); if (c.fullSha.isEmpty()) return; - bool ok = false; - QString name = QInputDialog::getText( - this, tr("Tag commit"), tr("Tag name:"), QLineEdit::Normal, - QStringLiteral("milestone-%1") - .arg(c.when.toString(QStringLiteral("yyyyMMdd"))), - &ok); - if (!ok || name.isEmpty()) + + const QString defaultName = QStringLiteral("milestone-%1") + .arg(c.when.toString(QStringLiteral("yyyyMMdd"))); + const QByteArray doc = buildTextPromptDoc( + QStringLiteral("name"), QObject::tr("Tag name:"), defaultName, + QStringLiteral("tag"), QObject::tr("Tag")); + + char resultBuf[kModalResultBufSize]; + const int rc = m_ctx->ui_modal_run( + m_ctx->module_handle, QObject::tr("Tag commit").toUtf8().constData(), + doc.constData(), resultBuf, sizeof(resultBuf)); + if (rc != 0) return; - QString err; - if (!m_repo.tag(name, c.fullSha, &err)) - QMessageBox::warning(this, tr("Tag failed"), err); + + QJsonParseError err{}; + const QJsonDocument jd = QJsonDocument::fromJson(QByteArray(resultBuf), &err); + if (err.error != QJsonParseError::NoError || !jd.isObject()) + return; + const QJsonObject result = jd.object(); + if (result.value(QStringLiteral("button")).toString() != QLatin1String("tag")) + return; + const QString name = result.value(QStringLiteral("fields")) + .toObject() + .value(QStringLiteral("name")) + .toString(); + if (name.isEmpty()) + return; + + QString errMsg; + if (!m_repo.tag(name, c.fullSha, &errMsg)) + notify(1, QObject::tr("Tag failed"), errMsg); } -void GitVersioningPage::onDropClicked() +void GitVersioningPageController::onDropClicked() { - if (!confirm(tr("Drop last commit?"), - tr("This hard-resets HEAD by one commit. The working " - "tree is reset to the parent commit too. Continue?"))) + if (!confirm(QObject::tr("Drop last commit?"), + QObject::tr("This hard-resets HEAD by one commit. The working " + "tree is reset to the parent commit too. Continue?"))) return; - QString err; - if (!m_repo.dropHead(&err)) - QMessageBox::warning(this, tr("Drop failed"), err); + QString errMsg; + if (!m_repo.dropHead(&errMsg)) + notify(1, QObject::tr("Drop failed"), errMsg); reloadHistory(); } -void GitVersioningPage::onRefresh() +void GitVersioningPageController::eventTrampoline(void* user_data, + const char* /*surface_id*/, + const char* node_id, + const char* event, + const char* value_json) { - reloadHistory(); + auto* self = static_cast(user_data); + if (!self || !node_id || !event) + return; + self->handleEvent(QString::fromUtf8(node_id), QString::fromUtf8(event), + value_json ? QString::fromUtf8(value_json) : QString()); +} + +void GitVersioningPageController::handleEvent(const QString& nodeId, + const QString& event, + const QString& valueJson) +{ + if (event == QLatin1String("click")) { + if (nodeId == QLatin1String("snapshot")) + onSnapshotClicked(); + else if (nodeId == QLatin1String("restore")) + onRestoreClicked(); + else if (nodeId == QLatin1String("tag")) + onTagClicked(); + else if (nodeId == QLatin1String("drop")) + onDropClicked(); + else if (nodeId == QLatin1String("refresh")) + reloadHistory(); + } else if ((event == QLatin1String("select") || + event == QLatin1String("activate")) && + nodeId == QLatin1String("commits")) { + onSelectionChanged(jsonStringValue(valueJson)); + } } diff --git a/launcher/plugin/plugins/GitVersioning/GitVersioningPage.h b/launcher/plugin/plugins/GitVersioning/GitVersioningPage.h index c8ba3af3..a0c6e0d5 100644 --- a/launcher/plugin/plugins/GitVersioning/GitVersioningPage.h +++ b/launcher/plugin/plugins/GitVersioning/GitVersioningPage.h @@ -1,8 +1,18 @@ /* SPDX-FileCopyrightText: 2026 Project Tick * SPDX-License-Identifier: Apache-2.0 * - * GitVersioningPage — instance page that exposes the per-instance - * commit history as a BasePage subclass. + * GitVersioningPageController — ABI 5 declarative-UI controller for the + * per-instance "Version History" page. + * + * This replaces the former GitVersioningPage (a QWidget + BasePage + * subclass). There is no QWidget here at all: the actual widget tree is + * built by the host's PluginUiRenderer from the "mmco-ui/1" JSON document + * this class maintains, and mounted fresh every time the instance window's + * page list is rebuilt (see PluginManager::createInstancePages). This + * object just owns the MMCO_UI_ANCHOR_INSTANCE_PAGE surface handle, the + * cached commit list, and the current row selection, and pushes updates + * through ui_surface_set / ui_surface_set_rows in response to events + * delivered through MMCOUiEventCallback. */ #pragma once @@ -10,68 +20,71 @@ #include "plugin/sdk/mmco_cxx_sdk.hpp" #include "GitRepo.h" -class GitVersioningPage : public QWidget, public BasePage +class GitVersioningPageController { - Q_OBJECT public: - /* Constructed from a string instance id + filesystem root rather - * than InstancePtr — keeps the page off the launcher type system. - * `ctx` is the MMCO context so destructive operations can route - * their confirmation prompts through the host's ui_confirm_dialog - * (S-tier UI API) rather than a plugin-local QMessageBox. It may - * be null, in which case the page falls back to QMessageBox. */ - GitVersioningPage(MMCOContext* ctx, const QString& instanceId, - const QString& instanceRoot, QWidget* parent = nullptr); + GitVersioningPageController(MMCOContext* ctx, QString instanceId, + QString instanceRoot); + + /* Builds the initial document from the current git state and + * registers the MMCO_UI_ANCHOR_INSTANCE_PAGE surface. Must be called + * once, before the instance's page list can be requested (i.e. + * before the instance window can be opened for this instance) — + * see GitVersioningPlugin.cpp's MMCO_HOOK_UI_MAIN_READY / + * MMCO_HOOK_INSTANCE_CREATED handlers. */ + void createSurface(); + + /* Tears the surface down early — used when the instance itself is + * removed while the plugin stays loaded. Safe to call more than + * once. NOT needed on plugin unload: the host tears down every + * surface a module still owns automatically before mmco_unload() + * runs (see PluginManager::releaseSurfacesForModule) — calling this + * afterwards would touch an already-freed handle. */ + void destroySurface(); + + /* Re-reads git state and pushes a fresh status line + row set to the + * surface. Called from the Refresh button's click event and — to + * keep the page as up to date as the old per-open BasePage + * reconstruction used to be — every time this instance's page list + * is about to be rebuilt (see GitVersioningPlugin.cpp's + * MMCO_HOOK_UI_INSTANCE_PAGES handler). */ + void reloadHistory(); - QString id() const override - { - return QStringLiteral("git-versioning"); - } - QString displayName() const override - { - return QObject::tr("Version History"); - } - QIcon icon() const override; - QString helpPage() const override - { - return QStringLiteral("Git-Versioning"); - } - bool shouldDisplay() const override - { - return true; - } + private: + void handleEvent(const QString& nodeId, const QString& event, + const QString& valueJson); + static void eventTrampoline(void* user_data, const char* surface_id, + const char* node_id, const char* event, + const char* value_json); - private slots: - void onCommitClicked(); + void onSnapshotClicked(); void onRestoreClicked(); void onTagClicked(); void onDropClicked(); - void onRefresh(); - void onSelectionChanged(); + void onSelectionChanged(const QString& rowId); - private: - void buildUi(); - void reloadHistory(); GitCommit selectedCommit() const; - /* Destructive-action confirmation. Routes through the host's - * ui_confirm_dialog when a context is available so the prompt is - * styled and themed like the rest of the launcher; otherwise it - * falls back to a plugin-local QMessageBox. Returns true when the - * user confirms. */ + /* Destructive-action confirmation, routed through the host's + * ui_confirm_dialog (unchanged since ABI 2 — a single opaque host + * dialog, not a persistent widget, so ABI 5 left it as-is). */ bool confirm(const QString& title, const QString& message) const; + /* Info/warning toast, routed through the host's ui_show_message + * (also unchanged since ABI 2). type: 0=info, 1=warning. */ + void notify(int type, const QString& title, const QString& message) const; + + QJsonObject buildDocument() const; + QJsonArray buildRows() const; + QString buildStatusText() const; + void setStatusText(const QString& text) const; + void pushRows() const; + void setNodeEnabled(const QString& nodeId, bool enabled) const; MMCOContext* m_ctx = nullptr; QString m_instanceId; QString m_instanceRoot; GitRepo m_repo; QList m_commits; - - QLabel* m_statusLabel = nullptr; - QTreeWidget* m_tree = nullptr; - QPushButton* m_commitBtn = nullptr; - QPushButton* m_restoreBtn = nullptr; - QPushButton* m_tagBtn = nullptr; - QPushButton* m_dropBtn = nullptr; - QPushButton* m_refreshBtn = nullptr; + QString m_selectedSha; /* full sha of the selected commit row, or empty */ + void* m_surface = nullptr; }; diff --git a/launcher/plugin/plugins/GitVersioning/GitVersioningPlugin.cpp b/launcher/plugin/plugins/GitVersioning/GitVersioningPlugin.cpp index 3ea1f442..8bd30a11 100644 --- a/launcher/plugin/plugins/GitVersioning/GitVersioningPlugin.cpp +++ b/launcher/plugin/plugins/GitVersioning/GitVersioningPlugin.cpp @@ -3,21 +3,46 @@ * * GitVersioningPlugin — MMCO entry point. * + * ABI 5 — declarative UI surfaces (S33). Every widget this plugin shows + * is now described as an "mmco-ui/1" JSON document handed to + * ui_surface_create; the host renders and owns the actual widget tree. + * There is no QWidget/BasePage/allWidgets()/findChild anywhere in this + * plugin any more. + * * Hooks: - * APP_INITIALIZED — register settings + verify git is - * reachable. - * GLOBAL_SETTINGS_ABOUT_TO_OPEN — inject the global auto-snapshot - * checkbox into the MeshMC page. - * INSTANCE_SETTINGS_PAGE_* — (ABI 3) inject a per-instance - * override of the auto-snapshot - * setting, persisted via S24. - * UI_INSTANCE_PAGES — inject a "Version History" page per - * instance. - * INSTANCE_PRE_LAUNCH — auto-snapshot the instance if the - * effective (per-instance / global) - * setting is on. + * APP_INITIALIZED — log whether system git was found (the + * availability check itself already ran, in + * mmco_init(), so the global-settings surface + * reflects it from the very first paint). + * UI_MAIN_READY — instances loaded by the host before this + * plugin's mmco_init() ran (i.e. every + * instance that already existed at launch) + * get their per-instance surfaces created + * here, once the instance list is known to be + * populated (see SystemTray's identical + * reasoning for using this hook instead of + * mmco_init()). + * INSTANCE_CREATED — create the two per-instance surfaces + * (INSTANCE_SETTINGS override group + + * INSTANCE_PAGE "Version History") for a + * newly added instance. + * INSTANCE_REMOVED — destroy both, freeing the plugin-side + * GitVersioningPageController + GitRepo. + * UI_INSTANCE_PAGES — fires every time an instance window's page + * list is (re)built, *before* + * PluginManager::createInstancePages() reads + * our surface's document. We don't append + * anything to page_list_handle any more (that + * was the pre-ABI-5 mechanism) — we just reuse + * this moment to refresh the git history, so + * the page is as current as the old + * reconstruct-a-fresh-BasePage-per-open model + * used to be. + * INSTANCE_PRE_LAUNCH — auto-snapshot the instance if the effective + * (per-instance / global) setting is on. + * Unchanged. * - * Settings model (ABI 3 / S24): + * Settings model (ABI 3 / S24, unchanged by ABI 5): * - SETTING_AUTO_SNAPSHOT is a normal app setting AND an * instance-overridable setting, gated by SETTING_INSTANCE_OVERRIDE. * - At launch we read the *effective* per-instance value, which the @@ -28,6 +53,8 @@ #include "GitRepo.h" #include "GitVersioningPage.h" #include +#include +#include MMCO_DEFINE_MODULE("GitVersioning", "1.0.0", "Project Tick", "Track instance changes as Git commits — snapshot, restore, " @@ -39,9 +66,24 @@ static constexpr const char SETTING_AUTO_SNAPSHOT[] = "plugin.git_versioning.AutoSnapshotBeforeLaunch"; static constexpr const char SETTING_INSTANCE_OVERRIDE[] = "plugin.git_versioning.override"; -static QObject* g_guard = nullptr; -static QCheckBox* g_checkbox = nullptr; static bool g_gitAvailable = false; +static void* g_globalSettingsSurface = nullptr; + +/* Everything this plugin shows for one instance: the INSTANCE_SETTINGS + * override-group surface (owned directly, it's just two toggles) and the + * INSTANCE_PAGE "Version History" surface (owned via the controller, + * which also carries the GitRepo + cached commit list). */ +struct InstanceUi { + QString instanceId; + QString instanceRoot; + void* settingsSurface = nullptr; + std::unique_ptr page; +}; +/* QHash is implicitly shared (copy-on-write), so its value type must stay + * copyable even with a single owner — a unique_ptr value breaks that. + * Store raw owning pointers instead, same as the pre-ABI-5 code's + * QHash did. */ +static QHash g_instances; static bool parseBool(const char* v) { @@ -94,6 +136,16 @@ static void ensureInstanceSettingsRegistered(const char* instanceId) SETTING_INSTANCE_OVERRIDE); } +static bool overrideEnabledFor(const char* instanceId) +{ + if (!g_ctx || !instanceId) + return false; + return g_ctx->instance_setting_contains(g_ctx->module_handle, instanceId, + SETTING_INSTANCE_OVERRIDE) && + parseBool(g_ctx->instance_setting_get(g_ctx->module_handle, instanceId, + SETTING_INSTANCE_OVERRIDE)); +} + /* Effective auto-snapshot value for a given instance: the host resolves * the override gate + global fallback for us via instance_setting_get. */ static bool autoSnapshotEnabledForInstance(const char* instanceId) @@ -104,193 +156,259 @@ static bool autoSnapshotEnabledForInstance(const char* instanceId) if (!g_ctx->instance_setting_contains(g_ctx->module_handle, instanceId, SETTING_AUTO_SNAPSHOT)) return autoSnapshotEnabled(); - return parseBool(g_ctx->instance_setting_get( - g_ctx->module_handle, instanceId, SETTING_AUTO_SNAPSHOT)); + return parseBool(g_ctx->instance_setting_get(g_ctx->module_handle, instanceId, + SETTING_AUTO_SNAPSHOT)); } -static void injectCheckboxIntoMeshMCPage() -{ - QWidget* meshMCPage = nullptr; - for (auto* w : qApp->allWidgets()) { - if (w->objectName() == QStringLiteral("MeshMCPage")) { - meshMCPage = w; - break; - } - } - if (!meshMCPage) - return; - - auto* layout = - meshMCPage->findChild(QStringLiteral("verticalLayout_9")); - if (!layout) - return; - - auto* groupBox = new QGroupBox(QObject::tr("Git Versioning")); - groupBox->setObjectName(QStringLiteral("gitVersioningGroupBox")); - auto* gl = new QVBoxLayout(groupBox); - - g_checkbox = new QCheckBox( - QObject::tr("Auto-snapshot instance state before every launch"), - groupBox); - g_checkbox->setObjectName(QStringLiteral("gitAutoSnapshotCheck")); - g_checkbox->setToolTip( - QObject::tr("Commit any pending changes to the instance's Git history " - "right before the JVM starts. The history lives in the " - "instance's .history/ directory.")); - gl->addWidget(g_checkbox); +/* ---- Global settings surface (replaces injectCheckboxIntoMeshMCPage) -- */ +static QByteArray buildGlobalSettingsDoc() +{ + QJsonArray children; + children.append(QJsonObject{ + {"type", "toggle"}, + {"id", "auto_snapshot"}, + {"props", + QJsonObject{ + {"label", "Auto-snapshot instance state before every launch"}, + {"value", autoSnapshotEnabled()}, + {"enabled", g_gitAvailable}}}}); if (!g_gitAvailable) { - auto* warn = new QLabel(QObject::tr( - "git is not installed — version history disabled.")); - warn->setStyleSheet(QStringLiteral("color: #cc6666;")); - gl->addWidget(warn); - g_checkbox->setEnabled(false); + children.append(QJsonObject{ + {"type", "text"}, + {"id", "git_warning"}, + {"props", + QJsonObject{ + {"text", "git is not installed — version history disabled."}}}}); } - - int spacerIdx = layout->count() - 1; - layout->insertWidget(spacerIdx, groupBox); - - g_checkbox->setChecked(autoSnapshotEnabled()); - - QObject::connect(g_checkbox, &QCheckBox::toggled, g_guard, - [](bool checked) { - if (g_ctx) - g_ctx->app_setting_set(g_ctx->module_handle, - SETTING_AUTO_SNAPSHOT, - checked ? "1" : "0"); - }); + const QJsonObject doc{ + {"type", "mmco-ui/1"}, + {"root", + QJsonObject{{"type", "column"}, {"id", "root"}, {"children", children}}}}; + return QJsonDocument(doc).toJson(QJsonDocument::Compact); } -/* ---- ABI 3 per-instance settings page (S24) ----------------------- */ - -/* Per-page widget bag, keyed by the page QWidget* so the LOADED / - * APPLYING hooks can find it again. Freed when the page is destroyed. */ -struct InstancePageWidgets { - QGroupBox* groupBox = nullptr; - QCheckBox* autoSnap = nullptr; - QByteArray instanceId; /* copied — survives the signal storm */ -}; -static QHash g_pageWidgets; - -static void syncInstanceWidgets(InstancePageWidgets* w) +static void on_global_settings_event(void*, const char*, const char* node_id, + const char* event, const char* value_json) { - if (!w || !g_ctx) + if (!g_ctx || !node_id || !event) return; - const char* id = w->instanceId.constData(); - const bool overrideEnabled = - g_ctx->instance_setting_contains(g_ctx->module_handle, id, - SETTING_INSTANCE_OVERRIDE) && - parseBool(g_ctx->instance_setting_get(g_ctx->module_handle, id, - SETTING_INSTANCE_OVERRIDE)); - w->groupBox->setChecked(overrideEnabled); - w->autoSnap->setChecked(autoSnapshotEnabledForInstance(id) && - w->autoSnap->isEnabled()); + if (std::strcmp(node_id, "auto_snapshot") != 0) + return; + if (std::strcmp(event, "change") != 0) + return; + const bool checked = value_json && std::strcmp(value_json, "true") == 0; + g_ctx->app_setting_set(g_ctx->module_handle, SETTING_AUTO_SNAPSHOT, + checked ? "1" : "0"); } -static void applyInstanceWidgets(InstancePageWidgets* w) +static void createGlobalSettingsSurface() { - if (!w || !g_ctx) + if (!g_ctx) return; - const char* id = w->instanceId.constData(); - ensureInstanceSettingsRegistered(id); - const bool overrideEnabled = w->groupBox->isChecked(); - g_ctx->instance_setting_set(g_ctx->module_handle, id, - SETTING_INSTANCE_OVERRIDE, - overrideEnabled ? "1" : "0"); - if (overrideEnabled) { - g_ctx->instance_setting_set(g_ctx->module_handle, id, - SETTING_AUTO_SNAPSHOT, - w->autoSnap->isChecked() ? "1" : "0"); - } else { - g_ctx->instance_setting_reset(g_ctx->module_handle, id, - SETTING_AUTO_SNAPSHOT); + const QByteArray json = buildGlobalSettingsDoc(); + g_globalSettingsSurface = g_ctx->ui_surface_create( + g_ctx->module_handle, MMCO_UI_ANCHOR_GLOBAL_SETTINGS, nullptr, + "Git Versioning", "git-scm", json.constData(), on_global_settings_event, + nullptr); +} + +/* ---- Per-instance settings override surface (replaces + * injectGroupIntoInstanceSettingsPage) -------------------------------- */ + +static QByteArray buildInstanceSettingsDoc(const char* instanceId) +{ + const bool overrideEnabled = overrideEnabledFor(instanceId); + const bool autoSnap = autoSnapshotEnabledForInstance(instanceId); + + QJsonArray children; + children.append(QJsonObject{ + {"type", "toggle"}, + {"id", "override_enabled"}, + {"props", + QJsonObject{{"label", "Override global Git Versioning settings"}, + {"value", overrideEnabled}, + {"enabled", g_gitAvailable}}}}); + children.append(QJsonObject{ + {"type", "toggle"}, + {"id", "auto_snapshot"}, + {"props", + QJsonObject{ + {"label", "Auto-snapshot instance state before every launch"}, + {"value", autoSnap}, + {"enabled", g_gitAvailable && overrideEnabled}}}}); + if (!g_gitAvailable) { + children.append(QJsonObject{ + {"type", "text"}, + {"id", "git_warning"}, + {"props", + QJsonObject{ + {"text", "git is not installed — version history disabled."}}}}); } + const QJsonObject doc{ + {"type", "mmco-ui/1"}, + {"root", + QJsonObject{{"type", "column"}, {"id", "root"}, {"children", children}}}}; + return QJsonDocument(doc).toJson(QJsonDocument::Compact); } -static void injectGroupIntoInstanceSettingsPage(QWidget* page, - const char* instanceId) +static void on_instance_settings_event(void* user_data, const char*, + const char* node_id, const char* event, + const char* value_json) { - if (!page || !instanceId) + auto* rec = static_cast(user_data); + if (!g_ctx || !rec || !node_id || !event) + return; + if (std::strcmp(event, "change") != 0) return; - ensureInstanceSettingsRegistered(instanceId); + const QByteArray idUtf8 = rec->instanceId.toUtf8(); + const bool checked = value_json && std::strcmp(value_json, "true") == 0; - auto* layout = - page->findChild(QStringLiteral("verticalLayout_8")); - if (!layout) - return; + if (std::strcmp(node_id, "override_enabled") == 0) { + g_ctx->instance_setting_set(g_ctx->module_handle, idUtf8.constData(), + SETTING_INSTANCE_OVERRIDE, + checked ? "1" : "0"); + if (!checked) { + g_ctx->instance_setting_reset(g_ctx->module_handle, idUtf8.constData(), + SETTING_AUTO_SNAPSHOT); + } + /* Keep the dependent toggle's enabled/value props in sync so the + * canonical document (what the next page-open renders from) never + * drifts from what's on screen right now. */ + const QJsonObject patch{ + {"enabled", g_gitAvailable && checked}, + {"value", autoSnapshotEnabledForInstance(idUtf8.constData())}}; + const QByteArray patchJson = + QJsonDocument(patch).toJson(QJsonDocument::Compact); + g_ctx->ui_surface_set(g_ctx->module_handle, rec->settingsSurface, + "auto_snapshot", patchJson.constData()); + } else if (std::strcmp(node_id, "auto_snapshot") == 0) { + if (!overrideEnabledFor(idUtf8.constData())) + return; /* toggle should be disabled in this state; ignore stray events */ + g_ctx->instance_setting_set(g_ctx->module_handle, idUtf8.constData(), + SETTING_AUTO_SNAPSHOT, checked ? "1" : "0"); + } +} + +/* ---- Per-instance lifecycle ----------------------------------------- */ - if (page->findChild( - QStringLiteral("gitVersioningInstanceGroupBox"))) +static void createInstanceUi(const QString& instanceId, const QString& instanceRoot) +{ + if (!g_ctx || instanceId.isEmpty() || g_instances.contains(instanceId)) return; - auto* groupBox = new QGroupBox( - QObject::tr("Override global Git Versioning settings"), page); - groupBox->setObjectName(QStringLiteral("gitVersioningInstanceGroupBox")); - groupBox->setCheckable(true); - auto* gl = new QVBoxLayout(groupBox); - - auto* autoSnap = new QCheckBox( - QObject::tr("Auto-snapshot instance state before every launch"), - groupBox); - autoSnap->setObjectName(QStringLiteral("gitInstanceAutoSnapshotCheck")); - autoSnap->setToolTip( - QObject::tr("Commit any pending changes to this instance's Git " - "history right before the JVM starts. The history lives " - "in the instance's .history/ directory.")); - autoSnap->setEnabled(g_gitAvailable); - gl->addWidget(autoSnap); + const QByteArray idUtf8 = instanceId.toUtf8(); + ensureInstanceSettingsRegistered(idUtf8.constData()); - if (!g_gitAvailable) { - auto* warn = new QLabel( - QObject::tr("git is not installed — version history disabled."), - groupBox); - warn->setStyleSheet(QStringLiteral("color: #cc6666;")); - gl->addWidget(warn); - } + auto* rec = new InstanceUi(); + rec->instanceId = instanceId; + rec->instanceRoot = instanceRoot; - int spacerIdx = layout->count() - 1; - layout->insertWidget(spacerIdx, groupBox); + const QByteArray doc = buildInstanceSettingsDoc(idUtf8.constData()); + rec->settingsSurface = g_ctx->ui_surface_create( + g_ctx->module_handle, MMCO_UI_ANCHOR_INSTANCE_SETTINGS, + idUtf8.constData(), "Git Versioning", nullptr, doc.constData(), + on_instance_settings_event, rec); - auto* bag = new InstancePageWidgets; - bag->groupBox = groupBox; - bag->autoSnap = autoSnap; - bag->instanceId = QByteArray(instanceId); - g_pageWidgets.insert(page, bag); + rec->page = std::make_unique(g_ctx, instanceId, + instanceRoot); + rec->page->createSurface(); - QObject::connect(page, &QObject::destroyed, qApp, [page]() { - auto it = g_pageWidgets.find(page); - if (it != g_pageWidgets.end()) { - delete it.value(); - g_pageWidgets.erase(it); - } - }); + g_instances.insert(instanceId, rec); +} - syncInstanceWidgets(bag); +static void destroyInstanceUi(const QString& instanceId) +{ + auto it = g_instances.find(instanceId); + if (it == g_instances.end()) + return; + InstanceUi* rec = it.value(); + if (g_ctx && rec->settingsSurface) + g_ctx->ui_surface_destroy(g_ctx->module_handle, rec->settingsSurface); + if (rec->page) + rec->page->destroySurface(); + g_instances.erase(it); + delete rec; } +/* ---- Hooks ------------------------------------------------------------ */ + static int on_app_initialized(void*, uint32_t, void*, void*) { - g_gitAvailable = GitRepo::gitAvailable(); - if (g_gitAvailable && g_ctx) { + if (!g_ctx) + return 0; + if (g_gitAvailable) { QByteArray msg = "git detected: " + GitRepo::gitVersion().toUtf8(); MMCO_LOG(g_ctx, msg.constData()); - } else if (g_ctx) { + } else { MMCO_WARN(g_ctx, "system git not found — GitVersioning will run in " "read-only mode (instance page still visible but " "every operation will fail gracefully)."); } + return 0; +} - g_guard = new QObject(); +/* Instances that already existed when this module loaded aren't known + * until the instance list has actually been populated — mirrors + * SystemTray's identical reasoning for building its tray menu here + * instead of in mmco_init(). */ +static int on_ui_main_ready(void*, uint32_t, void*, void*) +{ + if (!g_ctx) + return 0; + const int total = g_ctx->instance_count(g_ctx->module_handle); + for (int i = 0; i < total; ++i) { + /* Copy before the next call: the host returns strings in one + * per-module buffer, which instance_get_path() overwrites. */ + const char* rawId = g_ctx->instance_get_id(g_ctx->module_handle, i); + if (!rawId) + continue; + const QByteArray id(rawId); + const char* path = + g_ctx->instance_get_path(g_ctx->module_handle, id.constData()); + createInstanceUi(QString::fromUtf8(id), + path ? QString::fromUtf8(path) : QString()); + } return 0; } -/* MMCO_HOOK_GLOBAL_SETTINGS_ABOUT_TO_OPEN handler — replaces the - * legacy direct connect to Application::globalSettingsAboutToOpen. */ -static int on_global_settings_about_to_open(void*, uint32_t, void*, void*) +static int on_instance_created(void*, uint32_t, void* payload, void*) { - g_checkbox = nullptr; - QTimer::singleShot(0, qApp, injectCheckboxIntoMeshMCPage); + auto* info = static_cast(payload); + if (!info || !info->instance_id) + return 0; + createInstanceUi(QString::fromUtf8(info->instance_id), + info->instance_path ? QString::fromUtf8(info->instance_path) + : QString()); + return 0; +} + +static int on_instance_removed(void*, uint32_t, void* payload, void*) +{ + auto* info = static_cast(payload); + if (!info || !info->instance_id) + return 0; + destroyInstanceUi(QString::fromUtf8(info->instance_id)); + return 0; +} + +/* ABI 5: no BasePage is appended here any more — the INSTANCE_PAGE + * surface itself is what PluginManager::createInstancePages() renders, + * reading whatever document our controller last pushed. We reuse this + * "page list about to be (re)built" moment (fired once per instance + * window open, *before* createInstancePages() runs — see + * InstancePageProvider::getPages()) to refresh the git history one more + * time, so the page is as current as the old per-open BasePage + * reconstruction used to be. */ +static int on_instance_pages(void*, uint32_t, void* payload, void*) +{ + auto* evt = static_cast(payload); + if (!evt || !evt->instance_id) + return 0; + auto it = g_instances.find(QString::fromUtf8(evt->instance_id)); + if (it != g_instances.end() && it.value()->page) + it.value()->page->reloadHistory(); return 0; } @@ -334,62 +452,6 @@ static int on_pre_launch(void*, uint32_t, void* payload, void*) return 0; } -static int on_instance_pages(void*, uint32_t, void* payload, void*) -{ - auto* evt = static_cast(payload); - if (!evt || !evt->page_list_handle || !evt->instance_id) - return 0; - - auto* pages = static_cast*>(evt->page_list_handle); - - const QString instId = QString::fromUtf8(evt->instance_id); - const QString instRoot = - evt->instance_path ? QString::fromUtf8(evt->instance_path) : QString(); - pages->append(new GitVersioningPage(g_ctx, instId, instRoot)); - return 0; -} - -/* MMCO_HOOK_INSTANCE_SETTINGS_PAGE_CREATED — inject our override group - * box into the just-built per-instance settings page. */ -static int on_instance_settings_page_created(void*, uint32_t, void* payload, - void*) -{ - auto* evt = static_cast(payload); - if (!evt || !evt->page_handle || !evt->instance_id) - return 0; - injectGroupIntoInstanceSettingsPage(static_cast(evt->page_handle), - evt->instance_id); - return 0; -} - -/* MMCO_HOOK_INSTANCE_SETTINGS_PAGE_LOADED — page refreshed its values - * from the backing store; mirror them into our widgets. */ -static int on_instance_settings_page_loaded(void*, uint32_t, void* payload, - void*) -{ - auto* evt = static_cast(payload); - if (!evt || !evt->page_handle) - return 0; - auto it = g_pageWidgets.find(static_cast(evt->page_handle)); - if (it != g_pageWidgets.end()) - syncInstanceWidgets(it.value()); - return 0; -} - -/* MMCO_HOOK_INSTANCE_SETTINGS_PAGE_APPLYING — page is about to commit; - * push our widgets back into the instance settings via S24. */ -static int on_instance_settings_page_applying(void*, uint32_t, void* payload, - void*) -{ - auto* evt = static_cast(payload); - if (!evt || !evt->page_handle) - return 0; - auto it = g_pageWidgets.find(static_cast(evt->page_handle)); - if (it != g_pageWidgets.end()) - applyInstanceWidgets(it.value()); - return 0; -} - extern "C" { MMCO_EXPORT int mmco_init(MMCOContext* ctx) @@ -398,30 +460,21 @@ MMCO_EXPORT int mmco_init(MMCOContext* ctx) MMCO_LOG(ctx, "GitVersioning initialising…"); ensureSettingRegistered(); + g_gitAvailable = GitRepo::gitAvailable(); + createGlobalSettingsSurface(); ctx->hook_register(ctx->module_handle, MMCO_HOOK_APP_INITIALIZED, on_app_initialized, nullptr); - ctx->hook_register(ctx->module_handle, - MMCO_HOOK_GLOBAL_SETTINGS_ABOUT_TO_OPEN, - on_global_settings_about_to_open, nullptr); - ctx->hook_register(ctx->module_handle, MMCO_HOOK_INSTANCE_PRE_LAUNCH, - on_pre_launch, nullptr); + ctx->hook_register(ctx->module_handle, MMCO_HOOK_UI_MAIN_READY, + on_ui_main_ready, nullptr); + ctx->hook_register(ctx->module_handle, MMCO_HOOK_INSTANCE_CREATED, + on_instance_created, nullptr); + ctx->hook_register(ctx->module_handle, MMCO_HOOK_INSTANCE_REMOVED, + on_instance_removed, nullptr); ctx->hook_register(ctx->module_handle, MMCO_HOOK_UI_INSTANCE_PAGES, on_instance_pages, nullptr); - ctx->hook_register(ctx->module_handle, - MMCO_HOOK_INSTANCE_SETTINGS_PAGE_CREATED, - on_instance_settings_page_created, nullptr); - ctx->hook_register(ctx->module_handle, - MMCO_HOOK_INSTANCE_SETTINGS_PAGE_LOADED, - on_instance_settings_page_loaded, nullptr); - ctx->hook_register(ctx->module_handle, - MMCO_HOOK_INSTANCE_SETTINGS_PAGE_APPLYING, - on_instance_settings_page_applying, nullptr); - - ctx->ui_register_instance_action( - ctx->module_handle, "Version History", - "View and manage the instance's snapshot history", "version-control", - "git-versioning"); + ctx->hook_register(ctx->module_handle, MMCO_HOOK_INSTANCE_PRE_LAUNCH, + on_pre_launch, nullptr); MMCO_LOG(ctx, "GitVersioning ready."); return 0; @@ -431,11 +484,16 @@ MMCO_EXPORT void mmco_unload() { if (g_ctx) MMCO_LOG(g_ctx, "GitVersioning unloading."); - qDeleteAll(g_pageWidgets); - g_pageWidgets.clear(); + /* Every surface this module still owns (global settings + every + * per-instance settings/page surface) was already torn down by + * PluginManager::releaseSurfacesForModule() before this call — see + * GitVersioningPageController::destroySurface()'s comment. We only + * need to free our own heap state here, never touch a surface + * handle again. */ + qDeleteAll(g_instances); + g_instances.clear(); g_ctx = nullptr; - g_checkbox = nullptr; - g_guard = nullptr; + g_globalSettingsSurface = nullptr; } } /* extern "C" */ diff --git a/launcher/plugin/plugins/LinuxPerf/CMakeLists.txt b/launcher/plugin/plugins/LinuxPerf/CMakeLists.txt index 4f5d3fbb..d600eb0c 100644 --- a/launcher/plugin/plugins/LinuxPerf/CMakeLists.txt +++ b/launcher/plugin/plugins/LinuxPerf/CMakeLists.txt @@ -14,7 +14,7 @@ if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) set(MeshMC_QT_VERSION_MAJOR "6" CACHE STRING "Major Qt version to build against (5 or 6)") set(QT_VERSION_MAJOR "${MeshMC_QT_VERSION_MAJOR}") - find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Core Widgets Gui Network) + find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Core Gui Network) find_package(MeshMC_SDK REQUIRED) set(MESHMC_PLUGIN_STAGING_DIR "${CMAKE_BINARY_DIR}/mmcmodules" CACHE PATH diff --git a/launcher/plugin/plugins/LinuxPerf/LinuxPerfPlugin.cpp b/launcher/plugin/plugins/LinuxPerf/LinuxPerfPlugin.cpp index c9af1acb..246b2226 100644 --- a/launcher/plugin/plugins/LinuxPerf/LinuxPerfPlugin.cpp +++ b/launcher/plugin/plugins/LinuxPerf/LinuxPerfPlugin.cpp @@ -5,8 +5,11 @@ #include "plugin/sdk/mmco_cxx_sdk.hpp" #include "vendor/gamemode_client.h" #include +#include #include +#include + MMCO_DEFINE_MODULE("Linux Performance Tools", "1.2.0", "Project Tick", "MangoHud FPS overlay and GameMode performance integration " "for Minecraft on Linux", @@ -20,10 +23,7 @@ static constexpr const char SETTING_INSTANCE_OVERRIDE[] = "plugin.linuxperf.override"; static MMCOContext* g_ctx = nullptr; -static QObject* g_guard = nullptr; - -static QCheckBox* g_mangoCheckbox = nullptr; -static QCheckBox* g_gamemodeCheckbox = nullptr; +static void* g_globalSurface = nullptr; /* ABI 5 GLOBAL_SETTINGS surface */ static bool is_flatpak() { @@ -73,36 +73,6 @@ static bool gamemoderun_available() return !gamemoderun_executable().isEmpty(); } -static QString mangohud_missing_tooltip() -{ - if (is_flatpak()) { - return QObject::tr( - "MangoHud is not mounted inside this Flatpak sandbox.\n" - "Install the org.freedesktop.Platform.VulkanLayer.MangoHud branch\n" - "that matches the base runtime and restart MeshMC. A mismatched\n" - "branch will not appear under /usr/lib/extensions/vulkan."); - } - - return QObject::tr( - "MangoHud is not installed or not found on PATH.\n" - "Install it (package: mangohud) and reopen this dialog."); -} - -static QString gamemode_missing_tooltip() -{ - if (is_flatpak()) { - return QObject::tr( - "GameMode is not available inside this Flatpak sandbox.\n" - "Bundle the GameMode client tools in the Flatpak and allow access " - "to\n" - "com.feralinteractive.GameMode on the session bus."); - } - - return QObject::tr( - "gamemoderun is not installed or not found on PATH.\n" - "Install GameMode and restart MeshMC to enable this option."); -} - static QString gamemode_status_text() { int status = gamemode_query_status(); @@ -213,251 +183,221 @@ static bool isGamemodeEnabledForInstance(const char* instanceId) return ctxBoolInstance(instanceId, SETTING_GAMEMODE); } -static void injectCheckboxesIntoMinecraftPage() +/* ── Settings UI: declarative ABI 5 surfaces ──────────────────────── * + * + * Replaces injectCheckboxesIntoMinecraftPage()/ + * injectCheckboxesIntoInstanceSettingsPage()'s allWidgets()/findChild + * walks with two kinds of `ui_surface_create` calls: one GLOBAL_SETTINGS + * surface (created once, like NVIDIAPrime/SystemTray) and one + * INSTANCE_SETTINGS surface per instance, created lazily the first time + * the host signals that instance's settings context + * (MMCO_HOOK_INSTANCE_SETTINGS_PAGE_CREATED) and refreshed on every + * subsequent open. There is no separate "apply" step any more — each + * toggle persists its own setting immediately on its "change" event, + * the same way SystemTray's/NVIDIAPrime's settings toggles do. */ + +static QJsonObject buildGlobalDoc() { - /* Locate the MinecraftPage widget by objectName (set in the .ui file). */ - QWidget* mcPage = nullptr; - for (auto* w : qApp->allWidgets()) { - if (w->objectName() == QStringLiteral("MinecraftPage")) { - mcPage = w; - break; - } + const bool mangoAvail = mangohud_available(); + const bool gmAvail = gamemoderun_available(); + + QJsonArray children{ + QJsonObject{ + {"type", "toggle"}, + {"id", "mangohud"}, + {"props", + QJsonObject{ + {"label", + "Enable MangoHud overlay (FPS / GPU / CPU metrics)"}, + {"value", isMangohudEnabled() && mangoAvail}, + {"enabled", mangoAvail}}}}, + QJsonObject{ + {"type", "toggle"}, + {"id", "gamemode"}, + {"props", + QJsonObject{ + {"label", "Enable GameMode (CPU / scheduler performance " + "optimisations)"}, + {"value", isGamemodeEnabled() && gmAvail}, + {"enabled", gmAvail}}}}}; + + if (gmAvail) { + children.append(QJsonObject{ + {"type", "text"}, + {"id", "gamemode_status"}, + {"props", QJsonObject{{"text", gamemode_status_text()}}}}); } - if (!mcPage) - return; - /* Find the vertical layout that hosts the Minecraft tab content. */ - auto* layout = - mcPage->findChild(QStringLiteral("verticalLayout_3")); - if (!layout) + return QJsonObject{ + {"type", "mmco-ui/1"}, + {"root", QJsonObject{{"type", "column"}, + {"id", "root"}, + {"children", children}}}}; +} + +static void on_global_surface_event(void* /*ud*/, const char* /*surface_id*/, + const char* node_id, const char* event, + const char* value_json) +{ + if (!g_ctx || !node_id || !event || std::strcmp(event, "change") != 0) return; - /* Skip injection if our group box is already present (re-open guard). */ - if (mcPage->findChild(QStringLiteral("linuxPerfGroupBox"))) + const bool checked = value_json && std::strcmp(value_json, "true") == 0; + const QString id = QString::fromUtf8(node_id); + if (id == QLatin1String("mangohud")) + g_ctx->app_setting_set(g_ctx->module_handle, SETTING_MANGOHUD, + checked ? "1" : "0"); + else if (id == QLatin1String("gamemode")) + g_ctx->app_setting_set(g_ctx->module_handle, SETTING_GAMEMODE, + checked ? "1" : "0"); +} + +static void create_global_settings_surface() +{ + if (!g_ctx) return; + const QByteArray json = + QJsonDocument(buildGlobalDoc()).toJson(QJsonDocument::Compact); + g_globalSurface = g_ctx->ui_surface_create( + g_ctx->module_handle, MMCO_UI_ANCHOR_GLOBAL_SETTINGS, nullptr, + "Linux Performance Tools", nullptr, json.constData(), + on_global_surface_event, nullptr); +} - auto* groupBox = new QGroupBox(QObject::tr("Linux Performance Tools")); - groupBox->setObjectName(QStringLiteral("linuxPerfGroupBox")); - auto* groupLayout = new QVBoxLayout(groupBox); +/* Per-instance surface bag, keyed by instance id so + * MMCO_HOOK_INSTANCE_REMOVED can tear the surface down and so the + * surface's own event callback (which only gets the opaque `surface_id` + * the host assigned, not the instance id) can recover which instance it + * belongs to via `user_data`. */ +struct InstanceSurfaceCtx { + QByteArray instanceId; + void* surface = nullptr; +}; +static QHash g_instanceSurfaces; - g_mangoCheckbox = new QCheckBox( - QObject::tr("Enable MangoHud overlay (FPS / GPU / CPU metrics)"), - groupBox); - g_mangoCheckbox->setObjectName(QStringLiteral("linuxPerfMangoHudCheck")); +static QJsonObject buildInstanceDoc(const char* instanceId) +{ + const bool overrideEnabled = + ctxBoolInstance(instanceId, SETTING_INSTANCE_OVERRIDE); const bool mangoAvail = mangohud_available(); - if (mangoAvail) { - QString mangoBin = mangohud_executable(); - g_mangoCheckbox->setToolTip( - QObject::tr( - "Injects the MangoHud overlay into Minecraft via the mangohud " - "wrapper.\n" - "Displays real-time FPS, frame timing, GPU and CPU metrics.\n" - "Works with both OpenGL and Vulkan (LWJGL2 / LWJGL3).\n\n" - "Found: %1") - .arg(mangoBin)); - } else { - g_mangoCheckbox->setToolTip(mangohud_missing_tooltip()); - } - g_mangoCheckbox->setEnabled(mangoAvail); - g_mangoCheckbox->setChecked(isMangohudEnabled() && mangoAvail); - groupLayout->addWidget(g_mangoCheckbox); - - g_gamemodeCheckbox = new QCheckBox( - QObject::tr( - "Enable GameMode (CPU / scheduler performance optimisations)"), - groupBox); - g_gamemodeCheckbox->setObjectName(QStringLiteral("linuxPerfGameModeCheck")); - g_gamemodeCheckbox->setToolTip(QObject::tr( - "Launches Minecraft via gamemoderun so that Feral Interactive's\n" - "GameMode daemon can apply CPU governor and scheduler optimisations\n" - "for the duration of the game session.\n" - "Requires GameMode to be installed (package: gamemode).")); const bool gmAvail = gamemoderun_available(); - g_gamemodeCheckbox->setEnabled(gmAvail); - if (!gmAvail) - g_gamemodeCheckbox->setToolTip(gamemode_missing_tooltip()); - g_gamemodeCheckbox->setChecked(isGamemodeEnabled() && gmAvail); - groupLayout->addWidget(g_gamemodeCheckbox); + + QJsonArray children{ + QJsonObject{ + {"type", "toggle"}, + {"id", "override"}, + {"props", + QJsonObject{ + {"label", "Override global Linux performance tools settings"}, + {"value", overrideEnabled}, + {"enabled", true}}}}, + QJsonObject{ + {"type", "toggle"}, + {"id", "mangohud"}, + {"props", + QJsonObject{ + {"label", + "Enable MangoHud overlay (FPS / GPU / CPU metrics)"}, + {"value", isMangohudEnabledForInstance(instanceId) && + mangoAvail}, + {"enabled", overrideEnabled && mangoAvail}}}}, + QJsonObject{ + {"type", "toggle"}, + {"id", "gamemode"}, + {"props", + QJsonObject{ + {"label", "Enable GameMode (CPU / scheduler performance " + "optimisations)"}, + {"value", isGamemodeEnabledForInstance(instanceId) && + gmAvail}, + {"enabled", overrideEnabled && gmAvail}}}}}; if (gmAvail) { - /* gamemode_query_status() returns: - * 0 = daemon running, no game registered - * 1 = daemon running, some game registered - * 2 = daemon running, this process registered - * -1 = daemon not reachable (not started or libgamemode unavailable) - * - * We show DAEMON reachability — not whether a game is currently - * using GameMode (which would always be 0 / "inactive" pre-launch - * and mislead the user into thinking the feature is broken). */ - auto* statusLabel = new QLabel(gamemode_status_text(), groupBox); - statusLabel->setObjectName(QStringLiteral("linuxPerfGameModeStatus")); - statusLabel->setWordWrap(true); - QFont f = statusLabel->font(); - f.setPointSizeF(f.pointSizeF() * 0.85); - statusLabel->setFont(f); - groupLayout->addWidget(statusLabel); + children.append(QJsonObject{ + {"type", "text"}, + {"id", "gamemode_status"}, + {"props", QJsonObject{{"text", gamemode_status_text()}}}}); } - int spacerIdx = layout->count() - 1; - layout->insertWidget(spacerIdx, groupBox); - - QObject::connect( - g_mangoCheckbox, &QCheckBox::toggled, g_guard, [](bool checked) { - if (g_ctx) - g_ctx->app_setting_set(g_ctx->module_handle, SETTING_MANGOHUD, - checked ? "1" : "0"); - }); - QObject::connect( - g_gamemodeCheckbox, &QCheckBox::toggled, g_guard, [](bool checked) { - if (g_ctx) - g_ctx->app_setting_set(g_ctx->module_handle, SETTING_GAMEMODE, - checked ? "1" : "0"); - }); + return QJsonObject{ + {"type", "mmco-ui/1"}, + {"root", QJsonObject{{"type", "column"}, + {"id", "root"}, + {"children", children}}}}; } -/* Per-page widget bag, keyed by the page QWidget* so we can refresh - * it when the page emits its loaded / about-to-apply edges via the - * matching ABI 3 hooks. */ -struct InstancePageWidgets { - QGroupBox* groupBox; - QCheckBox* mango; - QCheckBox* gm; - QByteArray instanceId; /* copied — survives signal storm */ -}; -static QHash g_pageWidgets; - -static void syncInstanceWidgets(InstancePageWidgets* w) +static void refreshInstanceSurface(InstanceSurfaceCtx* ctx) { - if (!w || !g_ctx) + if (!g_ctx || !ctx || !ctx->surface) return; - const char* id = w->instanceId.constData(); - const bool overrideEnabled = ctxBoolInstance(id, SETTING_INSTANCE_OVERRIDE); - w->groupBox->setChecked(overrideEnabled); - w->mango->setChecked(isMangohudEnabledForInstance(id) && - w->mango->isEnabled()); - w->gm->setChecked(isGamemodeEnabledForInstance(id) && w->gm->isEnabled()); + const QByteArray json = + QJsonDocument(buildInstanceDoc(ctx->instanceId.constData())) + .toJson(QJsonDocument::Compact); + g_ctx->ui_surface_update(g_ctx->module_handle, ctx->surface, + json.constData()); } -static void applyInstanceWidgets(InstancePageWidgets* w) +static void on_instance_surface_event(void* user_data, + const char* /*surface_id*/, + const char* node_id, const char* event, + const char* value_json) { - if (!w || !g_ctx) + auto* ctx = static_cast(user_data); + if (!g_ctx || !ctx || !node_id || !event || + std::strcmp(event, "change") != 0) return; - const char* id = w->instanceId.constData(); - ensureInstanceSettingsRegistered(id); - const bool overrideEnabled = w->groupBox->isChecked(); - g_ctx->instance_setting_set(g_ctx->module_handle, id, - SETTING_INSTANCE_OVERRIDE, - overrideEnabled ? "1" : "0"); - if (overrideEnabled) { - g_ctx->instance_setting_set(g_ctx->module_handle, id, SETTING_MANGOHUD, - w->mango->isChecked() ? "1" : "0"); - g_ctx->instance_setting_set(g_ctx->module_handle, id, SETTING_GAMEMODE, - w->gm->isChecked() ? "1" : "0"); - } else { - g_ctx->instance_setting_reset(g_ctx->module_handle, id, - SETTING_MANGOHUD); - g_ctx->instance_setting_reset(g_ctx->module_handle, id, - SETTING_GAMEMODE); + + const char* iid = ctx->instanceId.constData(); + const QString id = QString::fromUtf8(node_id); + const bool checked = value_json && std::strcmp(value_json, "true") == 0; + + ensureInstanceSettingsRegistered(iid); + + if (id == QLatin1String("override")) { + g_ctx->instance_setting_set(g_ctx->module_handle, iid, + SETTING_INSTANCE_OVERRIDE, + checked ? "1" : "0"); + if (!checked) { + g_ctx->instance_setting_reset(g_ctx->module_handle, iid, + SETTING_MANGOHUD); + g_ctx->instance_setting_reset(g_ctx->module_handle, iid, + SETTING_GAMEMODE); + } + /* The mangohud/gamemode toggles' enabled state (and, when the + * override was just turned off, their displayed value) depend + * on the override flag — refresh the whole document. */ + refreshInstanceSurface(ctx); + } else if (id == QLatin1String("mangohud")) { + g_ctx->instance_setting_set(g_ctx->module_handle, iid, + SETTING_MANGOHUD, checked ? "1" : "0"); + } else if (id == QLatin1String("gamemode")) { + g_ctx->instance_setting_set(g_ctx->module_handle, iid, + SETTING_GAMEMODE, checked ? "1" : "0"); } } -static void injectCheckboxesIntoInstanceSettingsPage(QWidget* page, - const char* instanceId) +static void ensure_instance_surface(const char* instanceId) { - if (!page || !instanceId) - return; - - ensureInstanceSettingsRegistered(instanceId); - - auto* layout = - page->findChild(QStringLiteral("verticalLayout_8")); - if (!layout) + if (!g_ctx || !instanceId) return; - if (page->findChild( - QStringLiteral("linuxPerfInstanceGroupBox"))) + const QByteArray idKey(instanceId); + auto it = g_instanceSurfaces.find(idKey); + if (it != g_instanceSurfaces.end()) { + refreshInstanceSurface(it.value()); return; - - auto* groupBox = new QGroupBox( - QObject::tr("Override global Linux performance tools settings"), page); - groupBox->setObjectName(QStringLiteral("linuxPerfInstanceGroupBox")); - groupBox->setCheckable(true); - - auto* groupLayout = new QVBoxLayout(groupBox); - - auto* mangoCheckbox = new QCheckBox( - QObject::tr("Enable MangoHud overlay (FPS / GPU / CPU metrics)"), - groupBox); - mangoCheckbox->setObjectName( - QStringLiteral("linuxPerfInstanceMangoHudCheck")); - const bool mangoAvail = mangohud_available(); - if (mangoAvail) { - QString mangoBin = mangohud_executable(); - mangoCheckbox->setToolTip( - QObject::tr( - "Injects the MangoHud overlay into Minecraft via the mangohud " - "wrapper.\n" - "Displays real-time FPS, frame timing, GPU and CPU metrics.\n" - "Works with both OpenGL and Vulkan (LWJGL2 / LWJGL3).\n\n" - "Found: %1") - .arg(mangoBin)); - } else { - mangoCheckbox->setToolTip(mangohud_missing_tooltip()); - } - mangoCheckbox->setEnabled(mangoAvail); - groupLayout->addWidget(mangoCheckbox); - - auto* gamemodeCheckbox = new QCheckBox( - QObject::tr( - "Enable GameMode (CPU / scheduler performance optimisations)"), - groupBox); - gamemodeCheckbox->setObjectName( - QStringLiteral("linuxPerfInstanceGameModeCheck")); - gamemodeCheckbox->setToolTip(QObject::tr( - "Launches Minecraft via gamemoderun so that Feral Interactive's\n" - "GameMode daemon can apply CPU governor and scheduler optimisations\n" - "for the duration of the game session.\n" - "Requires GameMode to be installed (package: gamemode).")); - const bool gmAvail = gamemoderun_available(); - gamemodeCheckbox->setEnabled(gmAvail); - if (!gmAvail) { - gamemodeCheckbox->setToolTip(gamemode_missing_tooltip()); } - groupLayout->addWidget(gamemodeCheckbox); - if (gmAvail) { - auto* statusLabel = new QLabel(gamemode_status_text(), groupBox); - statusLabel->setObjectName( - QStringLiteral("linuxPerfInstanceGameModeStatus")); - statusLabel->setWordWrap(true); - QFont f = statusLabel->font(); - f.setPointSizeF(f.pointSizeF() * 0.85); - statusLabel->setFont(f); - groupLayout->addWidget(statusLabel); - } - - int spacerIdx = layout->count() - 1; - layout->insertWidget(spacerIdx, groupBox); - - /* Stash the widget bag so the LOADED / APPLYING hook callbacks can - * find it again when the page emits its lifecycle signals. The - * bag is freed when the page is destroyed. */ - auto* bag = new InstancePageWidgets; - bag->groupBox = groupBox; - bag->mango = mangoCheckbox; - bag->gm = gamemodeCheckbox; - bag->instanceId = QByteArray(instanceId); - g_pageWidgets.insert(page, bag); - - QObject::connect(page, &QObject::destroyed, qApp, [page]() { - auto it = g_pageWidgets.find(page); - if (it != g_pageWidgets.end()) { - delete it.value(); - g_pageWidgets.erase(it); - } - }); + ensureInstanceSettingsRegistered(instanceId); - syncInstanceWidgets(bag); + auto* ctx = new InstanceSurfaceCtx{idKey, nullptr}; + const QByteArray json = + QJsonDocument(buildInstanceDoc(instanceId)).toJson(QJsonDocument::Compact); + ctx->surface = g_ctx->ui_surface_create( + g_ctx->module_handle, MMCO_UI_ANCHOR_INSTANCE_SETTINGS, instanceId, + "Linux Performance Tools", nullptr, json.constData(), + on_instance_surface_event, ctx); + g_instanceSurfaces.insert(idKey, ctx); } static int on_app_initialized(void* /*mh*/, uint32_t /*hook_id*/, @@ -472,62 +412,36 @@ static int on_app_initialized(void* /*mh*/, uint32_t /*hook_id*/, isMangohudEnabled() ? "yes" : "no", isGamemodeEnabled() ? "yes" : "no"); MMCO_LOG(g_ctx, buf); - - /* Settings dialog lifecycle is now driven via ABI 3 hooks; see - * the handlers below. */ - return 0; -} - -/* MMCO_HOOK_GLOBAL_SETTINGS_ABOUT_TO_OPEN — replaces the legacy - * direct connect to Application::globalSettingsAboutToOpen. */ -static int on_global_settings_about_to_open(void*, uint32_t, void*, void*) -{ - g_mangoCheckbox = nullptr; - g_gamemodeCheckbox = nullptr; - QTimer::singleShot(0, qApp, injectCheckboxesIntoMinecraftPage); return 0; } /* MMCO_HOOK_INSTANCE_SETTINGS_PAGE_CREATED — replaces direct - * Application::instanceSettingsPageCreated. The page_handle is an - * opaque QWidget*; we cast it (a Qt operation, allowed) and inject - * our group box. */ + * Application::instanceSettingsPageCreated + findChild("verticalLayout_8"). + * Fires every time an instance's settings dialog is (re)opened; we + * create the surface for that instance the first time and just refresh + * its values/enabled-state on subsequent opens (mirrors what the + * PAGE_LOADED hook used to do). */ static int on_instance_settings_page_created(void*, uint32_t, void* payload, void*) { auto* evt = static_cast(payload); - if (!evt || !evt->page_handle || !evt->instance_id) - return 0; - injectCheckboxesIntoInstanceSettingsPage( - static_cast(evt->page_handle), evt->instance_id); - return 0; -} - -/* MMCO_HOOK_INSTANCE_SETTINGS_PAGE_LOADED — page just refreshed its - * values from the backing store; mirror them into our checkboxes. */ -static int on_instance_settings_page_loaded(void*, uint32_t, void* payload, - void*) -{ - auto* evt = static_cast(payload); - if (!evt || !evt->page_handle) + if (!evt || !evt->instance_id) return 0; - auto it = g_pageWidgets.find(static_cast(evt->page_handle)); - if (it != g_pageWidgets.end()) - syncInstanceWidgets(it.value()); + ensure_instance_surface(evt->instance_id); return 0; } -/* MMCO_HOOK_INSTANCE_SETTINGS_PAGE_APPLYING — page is about to commit - * its values; push our widgets back into the instance settings. */ -static int on_instance_settings_page_applying(void*, uint32_t, void* payload, - void*) +static int on_instance_removed(void*, uint32_t, void* payload, void*) { - auto* evt = static_cast(payload); - if (!evt || !evt->page_handle) + auto* info = static_cast(payload); + if (!g_ctx || !info || !info->instance_id) return 0; - auto it = g_pageWidgets.find(static_cast(evt->page_handle)); - if (it != g_pageWidgets.end()) - applyInstanceWidgets(it.value()); + auto it = g_instanceSurfaces.find(QByteArray(info->instance_id)); + if (it != g_instanceSurfaces.end()) { + g_ctx->ui_surface_destroy(g_ctx->module_handle, it.value()->surface); + delete it.value(); + g_instanceSurfaces.erase(it); + } return 0; } @@ -614,22 +528,15 @@ MMCO_EXPORT int mmco_init(MMCOContext* ctx) MMCO_LOG(ctx, "LinuxPerf plugin initializing..."); ensureSettingsRegistered(); - g_guard = new QObject(); + create_global_settings_surface(); ctx->hook_register(ctx->module_handle, MMCO_HOOK_APP_INITIALIZED, on_app_initialized, nullptr); - ctx->hook_register(ctx->module_handle, - MMCO_HOOK_GLOBAL_SETTINGS_ABOUT_TO_OPEN, - on_global_settings_about_to_open, nullptr); ctx->hook_register(ctx->module_handle, MMCO_HOOK_INSTANCE_SETTINGS_PAGE_CREATED, on_instance_settings_page_created, nullptr); - ctx->hook_register(ctx->module_handle, - MMCO_HOOK_INSTANCE_SETTINGS_PAGE_LOADED, - on_instance_settings_page_loaded, nullptr); - ctx->hook_register(ctx->module_handle, - MMCO_HOOK_INSTANCE_SETTINGS_PAGE_APPLYING, - on_instance_settings_page_applying, nullptr); + ctx->hook_register(ctx->module_handle, MMCO_HOOK_INSTANCE_REMOVED, + on_instance_removed, nullptr); ctx->hook_register(ctx->module_handle, MMCO_HOOK_INSTANCE_PRE_LAUNCH, on_instance_pre_launch, nullptr); @@ -642,13 +549,12 @@ MMCO_EXPORT void mmco_unload() if (g_ctx) MMCO_LOG(g_ctx, "LinuxPerf plugin unloading."); g_ctx = nullptr; - g_mangoCheckbox = nullptr; - g_gamemodeCheckbox = nullptr; - for (auto* w : g_pageWidgets) - delete w; - g_pageWidgets.clear(); - /* g_guard intentionally not deleted — see NVIDIAPrime note */ - g_guard = nullptr; + g_globalSurface = nullptr; + /* PluginManager tears down every surface this module still owns + * when it unloads — we just free our own bookkeeping. */ + for (auto* ctx : g_instanceSurfaces) + delete ctx; + g_instanceSurfaces.clear(); } } /* extern "C" */ diff --git a/launcher/plugin/plugins/NVIDIAPrime/CMakeLists.txt b/launcher/plugin/plugins/NVIDIAPrime/CMakeLists.txt index a68fc0ca..33e87bfa 100644 --- a/launcher/plugin/plugins/NVIDIAPrime/CMakeLists.txt +++ b/launcher/plugin/plugins/NVIDIAPrime/CMakeLists.txt @@ -14,7 +14,7 @@ if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) set(MeshMC_QT_VERSION_MAJOR "6" CACHE STRING "Major Qt version to build against (5 or 6)") set(QT_VERSION_MAJOR "${MeshMC_QT_VERSION_MAJOR}") - find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Core Widgets Gui Network) + find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Core Gui Network) find_package(MeshMC_SDK REQUIRED) set(MESHMC_PLUGIN_STAGING_DIR "${CMAKE_BINARY_DIR}/mmcmodules" CACHE PATH diff --git a/launcher/plugin/plugins/NVIDIAPrime/NVIDIAPrimePlugin.cpp b/launcher/plugin/plugins/NVIDIAPrime/NVIDIAPrimePlugin.cpp index c6de1ac0..e6a28423 100644 --- a/launcher/plugin/plugins/NVIDIAPrime/NVIDIAPrimePlugin.cpp +++ b/launcher/plugin/plugins/NVIDIAPrime/NVIDIAPrimePlugin.cpp @@ -12,21 +12,24 @@ * __VK_LAYER_NV_optimus=NVIDIA_only * __GLX_VENDOR_LIBRARY_NAME=nvidia * - * The checkbox is injected into the existing Minecraft settings page - * at runtime. The setting is stored as plugin.nvidia_prime.enabled. + * ABI 5: the toggle is a declarative GLOBAL_SETTINGS surface (one + * `toggle` node, same shape as SystemTray's settings surface) instead + * of a QCheckBox injected into the Minecraft settings page via + * qApp->allWidgets()/findChild(). The setting is stored under the same + * key as before: plugin.nvidia_prime.enabled. */ #include "plugin/sdk/mmco_cxx_sdk.hpp" +#include + MMCO_DEFINE_MODULE("NVIDIA Prime Module", "1.0.0", "Project Tick", "Discrete GPU offload via NVIDIA Prime Render Offload", "MIT"); static MMCOContext* g_ctx = nullptr; static constexpr const char SETTING_KEY[] = "plugin.nvidia_prime.enabled"; -static QCheckBox* g_primeCheckbox = - nullptr; /* raw ptr — widget owned by dialog */ -static QObject* g_guard = nullptr; +static void* g_settingsSurface = nullptr; /* ABI 5 GLOBAL_SETTINGS surface */ static bool is_flatpak() { @@ -55,71 +58,53 @@ static void ensureSettingRegistered() g_ctx->app_setting_register(g_ctx->module_handle, SETTING_KEY, "0"); } -static void injectCheckboxIntoMinecraftPage() +/* ── Settings UI: one ABI 5 GLOBAL_SETTINGS surface ───────────────── * + * + * Replaces injectCheckboxIntoMinecraftPage()'s allWidgets()/findChild + * walk against MinecraftPage's "verticalLayout_3": the host renders + * this document as a titled section inside its own "Plugins" page + * every time the global Settings dialog opens, from whatever document + * is currently stored for this surface — so, like SystemTray's + * settings surface, this only needs to be created ONCE (here, from + * mmco_init()), not re-injected on every dialog open. */ + +static void on_settings_surface_event(void* /*ud*/, const char* /*surface_id*/, + const char* node_id, const char* event, + const char* value_json) { - /* Find the MinecraftPage widget (objectName set by .ui file) */ - QWidget* mcPage = nullptr; - for (auto* w : qApp->allWidgets()) { - if (w->objectName() == QStringLiteral("MinecraftPage")) { - mcPage = w; - break; - } - } - if (!mcPage) + if (!g_ctx || !node_id || !event) return; - - /* Find verticalLayout_3 inside the minecraftTab */ - auto* layout = - mcPage->findChild(QStringLiteral("verticalLayout_3")); - if (!layout) + if (QString::fromUtf8(node_id) != QLatin1String("enabled")) + return; + if (std::strcmp(event, "change") != 0) return; - /* Build the Performance group box */ - auto* groupBox = new QGroupBox(QObject::tr("Performance")); - groupBox->setObjectName(QStringLiteral("nvidiaPrimeGroupBox")); - auto* groupLayout = new QVBoxLayout(groupBox); - - g_primeCheckbox = new QCheckBox( - QObject::tr("Use discrete GPU (NVIDIA Prime Render Offload)"), - groupBox); - g_primeCheckbox->setObjectName(QStringLiteral("useNVIDIAPrimeCheck")); - g_primeCheckbox->setToolTip( - QObject::tr("Forces Minecraft to use the NVIDIA discrete GPU on " - "Optimus laptops.\nUses prime-run on Flatpak, or sets " - "environment variables directly otherwise.")); - groupLayout->addWidget(g_primeCheckbox); - - /* Insert before the spacer (last item in the layout) */ - int spacerIdx = layout->count() - 1; - layout->insertWidget(spacerIdx, groupBox); - - /* Load current setting */ - g_primeCheckbox->setChecked(is_enabled()); - - /* Save immediately when toggled — avoids relying on a - * close-dialog hook that would fire after the checkbox (and the - * page widget) is already destroyed. */ - QObject::connect( - g_primeCheckbox, &QCheckBox::toggled, g_guard, [](bool checked) { - if (!g_ctx) - return; - g_ctx->app_setting_set(g_ctx->module_handle, SETTING_KEY, - checked ? "1" : "0"); - }); + const bool checked = value_json && std::strcmp(value_json, "true") == 0; + g_ctx->app_setting_set(g_ctx->module_handle, SETTING_KEY, + checked ? "1" : "0"); } -/* Hook handler for MMCO_HOOK_GLOBAL_SETTINGS_ABOUT_TO_OPEN — the C-ABI - * replacement for the legacy - * QObject::connect(APPLICATION, - * &Application::globalSettingsAboutToOpen, ...) - * direct connection. The hook fires before the dialog is built; - * QTimer::singleShot(0, ...) defers the widget walk to the next - * event-loop turn so the MinecraftPage already exists. */ -static int on_global_settings_about_to_open(void*, uint32_t, void*, void*) +static void create_settings_surface() { - g_primeCheckbox = nullptr; - QTimer::singleShot(0, qApp, injectCheckboxIntoMinecraftPage); - return 0; + if (!g_ctx) + return; + const QJsonObject doc{ + {"type", "mmco-ui/1"}, + {"root", + QJsonObject{ + {"type", "toggle"}, + {"id", "enabled"}, + {"props", + QJsonObject{ + {"label", + "Use discrete GPU (NVIDIA Prime Render Offload)"}, + {"value", is_enabled()}, + {"enabled", true}}}}}}; + const QByteArray json = QJsonDocument(doc).toJson(QJsonDocument::Compact); + g_settingsSurface = g_ctx->ui_surface_create( + g_ctx->module_handle, MMCO_UI_ANCHOR_GLOBAL_SETTINGS, nullptr, + "NVIDIA Prime", nullptr, json.constData(), on_settings_surface_event, + nullptr); } static int on_app_initialized(void* /*mh*/, uint32_t /*hook_id*/, @@ -130,12 +115,6 @@ static int on_app_initialized(void* /*mh*/, uint32_t /*hook_id*/, buf, sizeof(buf), "NVIDIA Prime Render Offload is %s (Flatpak: %s)", is_enabled() ? "ENABLED" : "disabled", is_flatpak() ? "yes" : "no"); MMCO_LOG(g_ctx, buf); - - /* g_guard receives the QCheckBox::toggled connection later, when - * the settings dialog opens. Deleting (well, nulling) the guard - * in mmco_unload() severs every connection that has been anchored - * on it, so nothing dangles into unloaded .so memory. */ - g_guard = new QObject(); return 0; } @@ -178,14 +157,11 @@ MMCO_EXPORT int mmco_init(MMCOContext* ctx) MMCO_LOG(ctx, "NVIDIA Prime plugin initializing..."); ensureSettingRegistered(); + create_settings_surface(); ctx->hook_register(ctx->module_handle, MMCO_HOOK_APP_INITIALIZED, on_app_initialized, nullptr); - ctx->hook_register(ctx->module_handle, - MMCO_HOOK_GLOBAL_SETTINGS_ABOUT_TO_OPEN, - on_global_settings_about_to_open, nullptr); - ctx->hook_register(ctx->module_handle, MMCO_HOOK_INSTANCE_PRE_LAUNCH, on_instance_pre_launch, nullptr); @@ -198,15 +174,11 @@ MMCO_EXPORT void mmco_unload() if (g_ctx) { MMCO_LOG(g_ctx, "NVIDIA Prime plugin unloading."); } + /* PluginManager tears down every surface this module still owns + * when it unloads (see SurfaceRecord teardown in PluginManager.cpp) — + * we just drop our raw handle so we never touch it again. */ + g_settingsSurface = nullptr; g_ctx = nullptr; - g_primeCheckbox = nullptr; - /* g_guard is intentionally NOT deleted here. - * Deleting a QObject during Application teardown triggers - * Qt signal/slot doubly-linked-list surgery which can detect - * heap corruption caused by teardown ordering. - * Since dlclose() is skipped at shutdown, the code and data - * remain mapped — the OS reclaims everything at process exit. */ - g_guard = nullptr; } } /* extern "C" */ diff --git a/launcher/plugin/plugins/OfflineWiki/CMakeLists.txt b/launcher/plugin/plugins/OfflineWiki/CMakeLists.txt index 670e6d45..23278888 100644 --- a/launcher/plugin/plugins/OfflineWiki/CMakeLists.txt +++ b/launcher/plugin/plugins/OfflineWiki/CMakeLists.txt @@ -14,7 +14,7 @@ if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) set(MeshMC_QT_VERSION_MAJOR "6" CACHE STRING "Major Qt version to build against (5 or 6)") set(QT_VERSION_MAJOR "${MeshMC_QT_VERSION_MAJOR}") - find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Core Widgets Gui Network) + find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Core Gui Network) find_package(MeshMC_SDK REQUIRED) set(MESHMC_PLUGIN_STAGING_DIR "${CMAKE_BINARY_DIR}/mmcmodules" CACHE PATH @@ -40,13 +40,11 @@ set(WIKI_SOURCES OfflineWikiPlugin.cpp WikiRepoBundle.h WikiRepoBundle.cpp - WikiPage.h - WikiPage.cpp ) -# We use QTextBrowser for the article viewer (rich HTML/Markdown that -# works without QtWebEngine). This keeps the plugin small and avoids -# pulling Chromium into the launcher's distributable. +# The article viewer is a declarative "mmco-ui/1" surface (ABI 5); the +# host renders the Markdown article body, so this plugin has no QWidget +# code of its own and does not link Qt::Widgets directly. add_library(OfflineWiki MODULE ${WIKI_SOURCES}) target_link_libraries(OfflineWiki PRIVATE diff --git a/launcher/plugin/plugins/OfflineWiki/OfflineWikiPlugin.cpp b/launcher/plugin/plugins/OfflineWiki/OfflineWikiPlugin.cpp index 46eddad9..60ea65f8 100644 --- a/launcher/plugin/plugins/OfflineWiki/OfflineWikiPlugin.cpp +++ b/launcher/plugin/plugins/OfflineWiki/OfflineWikiPlugin.cpp @@ -5,15 +5,32 @@ * into / in the background and exposes it as a read-only * global settings page. The wiki is the plugin's only content source: * there is no bundle management and no ZIM support. + * + * ABI 5: the page is one declarative "mmco-ui/1" surface (anchor + * GLOBAL_SETTINGS) instead of a QWidget+BasePage. Layout: + * + * row + * column + * text_field "search" — filter box + * list "nav" — page titles (nav list, or search hits + * while the search box has text) + * text "article" — format:"markdown", the current page + * + * Internal wiki links are ordinary Markdown links whose href uses the + * "wiki:" scheme (WikiRepoBundle::rewriteLinks already produces this); + * clicking one fires the "article" node's click event with that href, + * which we resolve back into a showArticle() call — no 15th node type + * needed. Cf. plugin-abi5-spec.md §2's OfflineWiki row. */ #include "plugin/sdk/mmco_cxx_sdk.hpp" #include "WikiRepoBundle.h" -#include "WikiPage.h" -#include +#include +#include #include #include +#include MMCO_DEFINE_MODULE("OfflineWiki", "1.0.0", "Project Tick", "Offline, read-only viewer for the MeshMC wiki", @@ -24,12 +41,20 @@ namespace MMCOContext* g_ctx = nullptr; /* The single wiki bundle. Null until the clone has produced a usable - * checkout; WikiPage holds &g_wiki so it sees updates live. */ + * checkout. */ WikiRepoBundle* g_wiki = nullptr; - /* Open WikiPages register here so a background clone/pull can refresh - * them when it finishes. QPointers null out when a page is destroyed. */ - QList> g_openPages; + /* The one GLOBAL_SETTINGS surface, created once in mmco_init() and + * patched in place afterwards (ui_surface_set / ui_surface_set_rows) + * — the host re-renders it every time the Settings dialog opens, so + * unlike the old BasePage pattern there is nothing to re-create per + * open. */ + void* g_surface = nullptr; + + /* UI state mirrored locally so we can recompute the "nav" list and + * the "article" text after a background clone/pull completes. */ + QString g_searchQuery; + QString g_currentSlug; /* Guards against launching a second background sync while one is * already running (e.g. the settings dialog reopened mid-clone). */ @@ -42,6 +67,19 @@ namespace "https://github.com/Project-Tick/MeshMC.wiki.git"; constexpr const char kMeshmcWikiDirName[] = "meshmc-wiki"; + const QString kNoGitMarkdown = QStringLiteral( + "### Wiki not available\n\n" + "The MeshMC wiki is downloaded automatically using **git**, but no " + "`git` program was found on your system, so the wiki cannot be " + "fetched.\n\n" + "Install Git and restart MeshMC to download the wiki."); + + const QString kDownloadingMarkdown = QStringLiteral( + "### Downloading the MeshMC wiki…\n\n" + "The wiki is fetched in the background the first time you are " + "online and will appear here automatically once the download " + "finishes. If you are offline, MeshMC retries on every launch."); + QString pluginDataDir() { if (!g_ctx) @@ -60,11 +98,123 @@ namespace return QStandardPaths::findExecutable(QStringLiteral("git")); } - void refreshOpenPages() + /* --- JSON helpers ------------------------------------------------ * + * + * MMCOUiEventCallback's value_json is a JSON-encoded scalar (see + * PluginUiRenderer's jsonQuoteString): a "change"/"select"/"activate" + * payload arrives as a quoted, escaped JSON string literal, e.g. + * `"wiki:Page-Name"`. Wrap it back into an array to decode with + * QJsonDocument (which refuses a bare top-level string). */ + QString jsonUnquote(const char* valueJson) + { + if (!valueJson || !*valueJson) + return {}; + const QByteArray wrapped = "[" + QByteArray(valueJson) + "]"; + QJsonParseError err{}; + const QJsonDocument jd = QJsonDocument::fromJson(wrapped, &err); + if (err.error != QJsonParseError::NoError || !jd.isArray() || + jd.array().isEmpty()) + return QString::fromUtf8(valueJson); + return jd.array().at(0).toString(); + } + + /* --- Surface content ---------------------------------------------- */ + + QJsonArray navRowsJson() + { + QList entries; + if (g_wiki && g_wiki->isOpen()) { + entries = g_searchQuery.trimmed().isEmpty() + ? g_wiki->nav() + : g_wiki->searchTitles(g_searchQuery, 200); + } + QJsonArray rows; + for (const auto& e : entries) { + rows.append(QJsonObject{ + {"id", e.slug}, + {"cells", QJsonArray{e.title}}}); + } + return rows; + } + + /* Markdown shown in the "article" node for the current state: the + * selected article, an empty-state explanation while the wiki isn't + * available yet, or blank when the wiki is open but nothing has been + * picked from the list. */ + QString articleTextFor(const QString& slug) + { + if (g_wiki && g_wiki->isOpen()) { + if (slug.isEmpty()) + return QString(); + const QString md = g_wiki->renderArticleMarkdown(slug); + if (md.isEmpty()) + return QStringLiteral("*Article not found: %1*").arg(slug); + return md; + } + return gitExecutable().isEmpty() ? kNoGitMarkdown : kDownloadingMarkdown; + } + + void setArticleProps(const QJsonObject& props) + { + if (!g_ctx || !g_surface) + return; + const QByteArray json = QJsonDocument(props).toJson(QJsonDocument::Compact); + g_ctx->ui_surface_set(g_ctx->module_handle, g_surface, "article", + json.constData()); + } + + void showArticle(const QString& slug) + { + g_currentSlug = slug; + setArticleProps(QJsonObject{{"text", articleTextFor(slug)}}); + } + + void refreshNavRows() + { + if (!g_ctx || !g_surface) + return; + const QByteArray json = + QJsonDocument(navRowsJson()).toJson(QJsonDocument::Compact); + g_ctx->ui_surface_set_rows(g_ctx->module_handle, g_surface, "nav", + json.constData()); + } + + /* Called after a background clone/pull changes what's on disk: the + * nav list and the currently-shown article (or the empty state) both + * need to catch up. */ + void refreshSurfaceFromWiki() { - for (auto& p : g_openPages) - if (p) - p->refreshBundle(); + refreshNavRows(); + setArticleProps(QJsonObject{{"text", articleTextFor(g_currentSlug)}}); + } + + QJsonObject buildDocumentJson() + { + const QJsonObject searchField{ + {"type", "text_field"}, + {"id", "search"}, + {"props", + QJsonObject{{"placeholder", "Search…"}, {"value", g_searchQuery}}}}; + const QJsonObject navList{ + {"type", "list"}, + {"id", "nav"}, + {"props", + QJsonObject{{"columns", QJsonArray{"Title"}}, + {"rows", navRowsJson()}}}}; + const QJsonObject navColumn{ + {"type", "column"}, + {"id", "nav_col"}, + {"children", QJsonArray{searchField, navList}}}; + const QJsonObject article{ + {"type", "text"}, + {"id", "article"}, + {"props", + QJsonObject{{"format", "markdown"}, + {"text", articleTextFor(g_currentSlug)}}}}; + const QJsonObject root{{"type", "row"}, + {"id", "root"}, + {"children", QJsonArray{navColumn, article}}}; + return QJsonObject{{"type", "mmco-ui/1"}, {"root", root}}; } /* Try to (re)open the on-disk wiki checkout into g_wiki. Returns true @@ -165,42 +315,83 @@ namespace MMCO_LOG(g_ctx, haveCheckout ? "OfflineWiki: MeshMC wiki updated." : "OfflineWiki: MeshMC wiki cloned."); - // Open / re-open the checkout and refresh any open page. + // Open / re-open the checkout and refresh the surface. openWiki(); - refreshOpenPages(); + refreshSurfaceFromWiki(); } proc->deleteLater(); }); proc->start(); } -} // namespace -static int on_global_settings_pages(void*, uint32_t, void* payload, void*) -{ - auto* evt = static_cast(payload); - if (!evt || !evt->page_list_handle) - return 0; - auto* pages = static_cast*>(evt->page_list_handle); - - auto* page = new WikiPage(&g_wiki, !gitExecutable().isEmpty()); - pages->append(page); - - // Track the page so a background clone/pull can refresh its nav. - // Drop dead entries opportunistically and forget this one on destroy. - g_openPages.removeAll(QPointer(nullptr)); - g_openPages.append(QPointer(page)); - QObject::connect(page, &QObject::destroyed, page, [page]() { - g_openPages.removeAll(QPointer(page)); - }); - - // If we still have no wiki (offline first run, or an earlier failed - // clone), try again now that the user is looking at the page. Cheap - // no-op when a sync is already running. - if (!g_wiki) - startMeshmcWikiSync(); - return 0; -} + /* --- Surface event handling ---------------------------------------- */ + + void onSearchChanged(const QString& text) + { + g_searchQuery = text; + refreshNavRows(); + } + + void onNavPicked(const char* valueJson) + { + const QString slug = jsonUnquote(valueJson); + if (slug.isEmpty()) + return; + showArticle(slug); + } + + void onArticleLinkClicked(const QString& href) + { + if (href.isEmpty()) + return; + + // Internal wiki link (wiki:Page-Name[#fragment]) → render the + // target article. + if (href.startsWith(QStringLiteral("wiki:"))) { + QString slug = href.mid(5); + const int hash = slug.indexOf(QLatin1Char('#')); + if (hash >= 0) + slug = slug.left(hash); + if (!slug.isEmpty()) + showArticle(slug); + return; + } + + // A pure in-page anchor (#section): the host's markdown "text" + // node is a plain QLabel, which has no scrollToAnchor() — there + // is currently no way for a plugin to scroll to a fragment + // within a rendered markdown node, so this is a no-op. (Reported + // as a host-API gap; see migration report.) + if (!href.contains(QLatin1Char(':'))) + return; + + // Everything else (http/https/mailto/…) opens in the system + // browser; we never load remote content inside the offline + // viewer. + QDesktopServices::openUrl(QUrl(href)); + } + + void on_wiki_surface_event(void* /*user_data*/, const char* /*surface_id*/, + const char* node_id, const char* event, + const char* value_json) + { + if (!node_id || !event) + return; + const QString id = QString::fromUtf8(node_id); + const QString ev = QString::fromUtf8(event); + + if (id == QLatin1String("search") && ev == QLatin1String("change")) { + onSearchChanged(jsonUnquote(value_json)); + } else if (id == QLatin1String("nav") && + (ev == QLatin1String("select") || + ev == QLatin1String("activate"))) { + onNavPicked(value_json); + } else if (id == QLatin1String("article") && ev == QLatin1String("click")) { + onArticleLinkClicked(jsonUnquote(value_json)); + } + } +} // namespace extern "C" { @@ -216,10 +407,15 @@ MMCO_EXPORT int mmco_init(MMCOContext* ctx) MMCO_LOG(ctx, g_wiki ? "OfflineWiki: MeshMC wiki ready (cached copy)." : "OfflineWiki: no cached wiki yet."); - startMeshmcWikiSync(); + const QByteArray doc = + QJsonDocument(buildDocumentJson()).toJson(QJsonDocument::Compact); + g_surface = ctx->ui_surface_create( + ctx->module_handle, MMCO_UI_ANCHOR_GLOBAL_SETTINGS, nullptr, "Wiki", + "help-browser", doc.constData(), on_wiki_surface_event, nullptr); + if (!g_surface) + MMCO_ERR(ctx, "OfflineWiki: ui_surface_create() failed."); - ctx->hook_register(ctx->module_handle, MMCO_HOOK_UI_GLOBAL_SETTINGS_PAGES, - on_global_settings_pages, nullptr); + startMeshmcWikiSync(); MMCO_LOG(ctx, "OfflineWiki ready."); return 0; @@ -230,9 +426,10 @@ MMCO_EXPORT void mmco_unload() if (g_ctx) MMCO_LOG(g_ctx, "OfflineWiki unloading."); // Clear g_ctx first so any in-flight background git callback becomes a - // no-op (it checks g_ctx and only self-deletes its QProcess). + // no-op (it checks g_ctx and only self-deletes its QProcess). The host + // tears down g_surface itself once the module unloads. g_ctx = nullptr; - g_openPages.clear(); + g_surface = nullptr; delete g_wiki; g_wiki = nullptr; } diff --git a/launcher/plugin/plugins/OfflineWiki/WikiPage.cpp b/launcher/plugin/plugins/OfflineWiki/WikiPage.cpp deleted file mode 100644 index 74a1c06c..00000000 --- a/launcher/plugin/plugins/OfflineWiki/WikiPage.cpp +++ /dev/null @@ -1,188 +0,0 @@ -/* SPDX-FileCopyrightText: 2026 Project Tick - * SPDX-License-Identifier: Apache-2.0 */ - -#include "WikiPage.h" -#include "WikiRepoBundle.h" - -#include -#include -#include -#include -#include -#include - -WikiPage::WikiPage(WikiRepoBundle** bundle, bool gitAvailable, QWidget* parent) - : QWidget(parent), m_bundle(bundle), m_gitAvailable(gitAvailable) -{ - buildUi(); - rebuildNav(); -} - -void WikiPage::buildUi() -{ - auto* root = new QVBoxLayout(this); - - auto* topRow = new QHBoxLayout(); - topRow->addStretch(); - m_searchEdit = new QLineEdit(this); - m_searchEdit->setPlaceholderText(tr("Search…")); - topRow->addWidget(m_searchEdit, /*stretch=*/2); - connect(m_searchEdit, &QLineEdit::textChanged, this, - &WikiPage::onSearchTextChanged); - root->addLayout(topRow); - - auto* split = new QSplitter(Qt::Horizontal, this); - - auto* leftCol = new QWidget(split); - auto* lv = new QVBoxLayout(leftCol); - lv->setContentsMargins(0, 0, 0, 0); - m_nav = new QTreeWidget(leftCol); - m_nav->setHeaderHidden(true); - connect(m_nav, &QTreeWidget::itemSelectionChanged, this, - &WikiPage::onNavSelection); - lv->addWidget(m_nav, 2); - m_searchResults = new QListWidget(leftCol); - m_searchResults->setVisible(false); - connect(m_searchResults, &QListWidget::itemActivated, this, - [this](QListWidgetItem*) { onSearchHitChosen(); }); - connect(m_searchResults, &QListWidget::itemSelectionChanged, this, - &WikiPage::onSearchHitChosen); - lv->addWidget(m_searchResults, 1); - - split->addWidget(leftCol); - - m_view = new QTextBrowser(split); - // We handle every link ourselves: internal "wiki:" links navigate - // between articles, external links open in the system browser. So - // disable QTextBrowser's own navigation and route anchorClicked. - m_view->setOpenLinks(false); - m_view->setOpenExternalLinks(false); - connect(m_view, &QTextBrowser::anchorClicked, this, - &WikiPage::onAnchorClicked); - split->addWidget(m_view); - - split->setStretchFactor(0, 1); - split->setStretchFactor(1, 3); - - root->addWidget(split, /*stretch=*/1); -} - -void WikiPage::refreshBundle() -{ - rebuildNav(); -} - -void WikiPage::rebuildNav() -{ - m_nav->clear(); - - WikiRepoBundle* b = bundle(); - if (b && b->isOpen()) { - auto* rootItem = new QTreeWidgetItem(m_nav); - rootItem->setText(0, b->name()); - rootItem->setExpanded(true); - for (const auto& e : b->nav()) { - auto* it = new QTreeWidgetItem(rootItem); - it->setText(0, e.title); - it->setData(0, Qt::UserRole, e.slug); - } - return; - } - - // No wiki yet: explain why rather than leaving a blank viewer. - if (!m_gitAvailable) { - m_view->setHtml( - tr("

Wiki not available

" - "

The MeshMC wiki is downloaded automatically using " - "git, but no git program was found on your " - "system, so the wiki cannot be fetched.

" - "

Install Git and restart MeshMC to download the wiki.

")); - } else { - m_view->setHtml( - tr("

Downloading the MeshMC wiki…

" - "

The wiki is fetched in the background the first time you " - "are online and will appear here automatically once the " - "download finishes. If you are offline, MeshMC retries on " - "every launch.

")); - } -} - -void WikiPage::onNavSelection() -{ - auto items = m_nav->selectedItems(); - if (items.isEmpty()) - return; - const QString slug = items.first()->data(0, Qt::UserRole).toString(); - if (slug.isEmpty()) - return; - showArticle(slug); -} - -void WikiPage::showArticle(const QString& slug) -{ - WikiRepoBundle* b = bundle(); - if (!b || slug.isEmpty()) - return; - const QString html = b->renderArticleHtml(slug); - if (html.isEmpty()) { - m_view->setHtml(tr("

Article not found: %1

") - .arg(slug.toHtmlEscaped())); - return; - } - m_view->setHtml(html); - m_view->verticalScrollBar()->setValue(0); -} - -void WikiPage::onAnchorClicked(const QUrl& url) -{ - // Internal wiki link (wiki:Page-Name[#fragment]) → render the target. - if (url.scheme() == QStringLiteral("wiki")) { - QString slug = url.path(); - if (slug.isEmpty()) - slug = url.toString().mid(QStringLiteral("wiki:").size()); - int hash = slug.indexOf(QLatin1Char('#')); - if (hash >= 0) - slug = slug.left(hash); - showArticle(slug); - return; - } - - // Pure in-page anchor (#section) → let the browser scroll to it. - if (url.scheme().isEmpty() && !url.fragment().isEmpty()) { - m_view->scrollToAnchor(url.fragment()); - return; - } - - // Everything else (http/https/mailto/…) opens in the system browser; - // we never load remote content inside the offline viewer. - if (!url.scheme().isEmpty()) - QDesktopServices::openUrl(url); -} - -void WikiPage::onSearchTextChanged(const QString& text) -{ - m_searchResults->clear(); - WikiRepoBundle* b = bundle(); - if (!b || text.trimmed().isEmpty()) { - m_searchResults->setVisible(false); - return; - } - const auto hits = b->searchTitles(text, 200); - for (const auto& h : hits) { - auto* item = new QListWidgetItem(h.title); - item->setData(Qt::UserRole, h.slug); - m_searchResults->addItem(item); - } - m_searchResults->setVisible(!hits.isEmpty()); -} - -void WikiPage::onSearchHitChosen() -{ - auto* it = m_searchResults->currentItem(); - if (!it) - return; - const QString slug = it->data(Qt::UserRole).toString(); - if (slug.isEmpty()) - return; - showArticle(slug); -} diff --git a/launcher/plugin/plugins/OfflineWiki/WikiPage.h b/launcher/plugin/plugins/OfflineWiki/WikiPage.h deleted file mode 100644 index 5bad05c2..00000000 --- a/launcher/plugin/plugins/OfflineWiki/WikiPage.h +++ /dev/null @@ -1,72 +0,0 @@ -/* SPDX-FileCopyrightText: 2026 Project Tick - * SPDX-License-Identifier: Apache-2.0 - * - * WikiPage — global settings page for the MeshMC offline wiki. Two - * columns: - * • left: page list (+ search box) - * • right: article viewer - * - * The wiki itself (a single WikiRepoBundle) is owned by the plugin and - * may be null until the background clone finishes; the page renders an - * informative empty state in that case. - */ - -#pragma once - -#include "plugin/sdk/mmco_cxx_sdk.hpp" - -class QTextBrowser; -class QListWidget; -class WikiRepoBundle; - -class WikiPage : public QWidget, public BasePage -{ - Q_OBJECT - public: - /* `bundle` is a pointer-to-pointer owned by the plugin: *bundle is - * null until the MeshMC wiki has been cloned, and changes when a - * background clone completes. `gitAvailable` tells the page whether - * the host has a usable `git` binary so the empty state can explain - * why the wiki is (not yet) present. */ - WikiPage(WikiRepoBundle** bundle, bool gitAvailable, - QWidget* parent = nullptr); - - QString id() const override - { - return QStringLiteral("offline-wiki"); - } - QString displayName() const override - { - return QObject::tr("Offline Wiki"); - } - QIcon icon() const override - { - return QIcon::fromTheme(QStringLiteral("help-browser")); - } - - /* Re-read the wiki into the nav. Called by the plugin when a - * background clone/update changes what is available. */ - void refreshBundle(); - - private slots: - void onNavSelection(); - void onSearchTextChanged(const QString& text); - void onSearchHitChosen(); - void onAnchorClicked(const QUrl& url); - - private: - void buildUi(); - void rebuildNav(); - void showArticle(const QString& slug); - WikiRepoBundle* bundle() const - { - return m_bundle ? *m_bundle : nullptr; - } - - WikiRepoBundle** m_bundle = nullptr; // owned by the plugin - bool m_gitAvailable = true; - QTreeWidget* m_nav = nullptr; - QLineEdit* m_searchEdit = nullptr; - QListWidget* m_searchResults = nullptr; - QTextBrowser* m_view = nullptr; -}; diff --git a/launcher/plugin/plugins/OfflineWiki/WikiRepoBundle.cpp b/launcher/plugin/plugins/OfflineWiki/WikiRepoBundle.cpp index 181de42d..f56cc2ae 100644 --- a/launcher/plugin/plugins/OfflineWiki/WikiRepoBundle.cpp +++ b/launcher/plugin/plugins/OfflineWiki/WikiRepoBundle.cpp @@ -3,7 +3,6 @@ #include "WikiRepoBundle.h" -#include #include QString WikiRepoBundle::slugFromFileName(const QString& fileName) @@ -155,7 +154,38 @@ QString WikiRepoBundle::rewriteLinks(const QString& markdown) const return out; } -QString WikiRepoBundle::renderArticleHtml(const QString& slug) const +QString WikiRepoBundle::rewriteImagePaths(const QString& markdown) const +{ + // Resolve relative Markdown image targets (![alt](images/foo.png)) + // against the bundle root so the renderer can load bundled media + // without knowing the wiki's on-disk location. Absolute (http/https/ + // data:) targets are left alone. + const QString baseUrl = + QUrl::fromLocalFile(QDir(m_root).absolutePath() + QLatin1Char('/')) + .toString(); + static const QRegularExpression imgRef( + QStringLiteral(R"(!\[([^\]]*)\]\(([^)\s]+)\))")); + QString out; + int last = 0; + auto it = imgRef.globalMatch(markdown); + while (it.hasNext()) { + auto m = it.next(); + out += markdown.mid(last, m.capturedStart() - last); + const QString alt = m.captured(1); + const QString src = m.captured(2); + const bool absolute = src.contains(QStringLiteral("://")) || + src.startsWith(QStringLiteral("data:")); + if (absolute) + out += m.captured(0); + else + out += QStringLiteral("![%1](%2%3)").arg(alt, baseUrl, src); + last = m.capturedEnd(); + } + out += markdown.mid(last); + return out; +} + +QString WikiRepoBundle::renderArticleMarkdown(const QString& slug) const { auto it = m_articles.constFind(slug); if (it == m_articles.constEnd()) @@ -177,37 +207,8 @@ QString WikiRepoBundle::renderArticleHtml(const QString& slug) const } body = rewriteLinks(body); - - QTextDocument doc; - doc.setMarkdown(body); - QString html = doc.toHtml(); - - // Resolve relative image/src paths against the bundle root so the - // QTextBrowser can load bundled media. Absolute (http/https/data/ - // file/wiki) sources are left alone. - const QString baseUrl = - QUrl::fromLocalFile(QDir(m_root).absolutePath() + QLatin1Char('/')) - .toString(); - static const QRegularExpression srcAttr( - QStringLiteral(R"(src=\"([^\"]+)\")")); - QString rebuilt; - int last = 0; - auto sit = srcAttr.globalMatch(html); - while (sit.hasNext()) { - auto m = sit.next(); - rebuilt += html.mid(last, m.capturedStart() - last); - QString src = m.captured(1); - const bool absolute = src.contains(QStringLiteral("://")) || - src.startsWith(QStringLiteral("data:")) || - src.startsWith(QLatin1Char('/')); - if (absolute) - rebuilt += m.captured(0); - else - rebuilt += QStringLiteral("src=\"%1%2\"").arg(baseUrl, src); - last = m.capturedEnd(); - } - rebuilt += html.mid(last); - return rebuilt; + body = rewriteImagePaths(body); + return body; } QList WikiRepoBundle::searchTitles(const QString& query, diff --git a/launcher/plugin/plugins/OfflineWiki/WikiRepoBundle.h b/launcher/plugin/plugins/OfflineWiki/WikiRepoBundle.h index e02155d9..19456c4a 100644 --- a/launcher/plugin/plugins/OfflineWiki/WikiRepoBundle.h +++ b/launcher/plugin/plugins/OfflineWiki/WikiRepoBundle.h @@ -14,14 +14,17 @@ * /images/… — media referenced with relative paths. * * The page list is discovered by scanning *.md. It also rewrites the - * two wiki link styles into an internal "wiki:" URL scheme so the - * viewer can navigate between pages: + * two wiki link styles into an internal "wiki:" URL scheme, still as + * Markdown, so the declarative UI's markdown text node can navigate + * between pages via ordinary link-click events: * - * [[Page Name]] -> Page Name - * [text](Page-Name) -> text + * [[Page Name]] -> [Page Name](wiki:Page-Name) + * [text](Page-Name) -> [text](wiki:Page-Name) * * (links that are already absolute — http(s):, mailto:, #anchors, - * or existing files — are left untouched.) + * or existing files — are left untouched.) Relative image references + * (![alt](images/foo.png)) are rewritten to absolute file:// URLs so + * they resolve without the renderer knowing the bundle root. */ #pragma once @@ -60,9 +63,11 @@ class WikiRepoBundle /* Navigation list: Home first (if present), then alphabetical. */ QList nav() const; - /* Resolve a slug to fully-rendered HTML (internal links rewritten to - * the wiki: scheme, relative images resolved). Empty if not found. */ - QString renderArticleHtml(const QString& slug) const; + /* Resolve a slug to the article's rewritten Markdown source (internal + * links rewritten to the wiki: scheme, relative images resolved to + * absolute file:// URLs) — fed directly into a "text" UI node with + * format "markdown". Empty if not found. */ + QString renderArticleMarkdown(const QString& slug) const; /* Pages whose title contains `query` (case-insensitive substring). */ QList searchTitles(const QString& query, int limit = 200) const; @@ -79,6 +84,7 @@ class WikiRepoBundle }; QString rewriteLinks(const QString& markdown) const; + QString rewriteImagePaths(const QString& markdown) const; QString m_name; QString m_root; diff --git a/launcher/plugin/plugins/SystemTray/CMakeLists.txt b/launcher/plugin/plugins/SystemTray/CMakeLists.txt index f4f3a0fb..9b24d7c0 100644 --- a/launcher/plugin/plugins/SystemTray/CMakeLists.txt +++ b/launcher/plugin/plugins/SystemTray/CMakeLists.txt @@ -14,7 +14,7 @@ if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) set(MeshMC_QT_VERSION_MAJOR "6" CACHE STRING "Major Qt version to build against (5 or 6)") set(QT_VERSION_MAJOR "${MeshMC_QT_VERSION_MAJOR}") - find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Core Widgets Gui Network) + find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Core Gui Network) find_package(MeshMC_SDK REQUIRED) set(MESHMC_PLUGIN_STAGING_DIR "${CMAKE_BINARY_DIR}/mmcmodules" CACHE PATH diff --git a/launcher/plugin/plugins/SystemTray/SystemTrayPlugin.cpp b/launcher/plugin/plugins/SystemTray/SystemTrayPlugin.cpp index 613366c4..0f847bc9 100644 --- a/launcher/plugin/plugins/SystemTray/SystemTrayPlugin.cpp +++ b/launcher/plugin/plugins/SystemTray/SystemTrayPlugin.cpp @@ -9,8 +9,8 @@ * (up to MAX_INSTANCE_ENTRIES recent instances) * • Optional "minimize to tray" close-event filter * - * Everything is funnelled through the S19/S20 API surfaces exposed by - * PluginManager, so this plugin compiles cleanly against the public + * Everything is funnelled through the S19/S20/S33 API surfaces exposed + * by PluginManager, so this plugin compiles cleanly against the public * SDK and does not poke at MainWindow internals. * * Settings (all booleans, stored under the plugin's namespace): @@ -21,6 +21,16 @@ #include "plugin/sdk/mmco_cxx_sdk.hpp" +/* qApp (used below to invoke "quit" on the application object) is + * defined by whichever Q*Application header is included; the SDK + * header no longer pulls in (ABI 5 dropped Qt::Widgets + * from MeshMC::SDK), so this plugin includes the QtCore-only + * QCoreApplication header itself — QMetaObject::invokeMethod only + * needs a QObject*, which qApp still resolves to either way. */ +#include + +#include + /* ── dependencies ─────────────────────────────────────────────────── * * * SystemTray depends on DesktopNotifier ≥ 1.0.0. @@ -57,43 +67,22 @@ MMCO_DEFINE_MODULE_EX( /* ── module-local state ───────────────────────────────────────────── */ static MMCOContext* g_ctx = nullptr; -static void* g_tray = nullptr; /* QSystemTrayIcon* */ -static void* g_menu = nullptr; /* QMenu* */ -static void* g_launchMenu = nullptr; /* QMenu* (submenu) */ -static void* g_showAction = nullptr; -static void* g_hideAction = nullptr; -static void* g_quitAction = nullptr; +static void* g_tray = nullptr; /* QSystemTrayIcon* */ +static void* g_settingsSurface = nullptr; /* ABI 5 GLOBAL_SETTINGS surface */ static QObject* g_guard = nullptr; /* anchor for our Qt connections */ -static QCheckBox* g_enabledCheckbox = nullptr; /* The launcher-wide setting key we mirror our "enabled" plugin-local * setting onto. Lives in APPLICATION->settings() so it's visible in * Settings → MeshMC and can be toggled by the user without poking at * raw config files. The plugin still treats its own plugin-namespaced * "enabled" setting as the runtime source of truth — the global key - * just drives the UI checkbox and is mirrored back into the plugin + * just drives the checkbox and is mirrored back into the plugin * namespace whenever the user flips it. */ static constexpr const char SETTING_GLOBAL_ENABLED[] = "plugin.system_tray.Enabled"; static constexpr int MAX_INSTANCE_ENTRIES = 8; -/* Stable copies of instance IDs (the API guarantees the returned C - * string only until the next call on the same module — we have to copy - * before stashing for callbacks). */ -struct InstanceEntry { - std::string id; - std::string name; -}; -static QVector g_launchEntries; - -/* Per-action user-data wrapper passed to the C-style callback. We allocate - * one per entry and free them all when the submenu is rebuilt. */ -struct LaunchUserData { - int entryIndex; -}; -static QVector g_launchUserData; - static bool is_flatpak() { return QFile::exists(QStringLiteral("/.flatpak-info")); @@ -121,37 +110,110 @@ static void settingSetBool(const char* key, bool value) g_ctx->setting_set(g_ctx->module_handle, key, value ? "1" : "0"); } -/* ── action callbacks (C-linkage style) ───────────────────────────── */ +/* ── tray menu: build the declarative "mmco-tray-menu/1" doc ─────── * + * + * ABI 5 replaces the imperative tray_menu_create/add_action/ + * add_submenu family with one JSON document per tray_set_menu() call. + * The whole menu — including the "Launch instance" submenu contents — + * is rebuilt from scratch here and handed to tray_set_menu() again on + * every INSTANCE_CREATED/REMOVED, exactly like the old + * rebuild_launch_submenu() did with the imperative API. + * + * Each per-instance entry's node id is "launch:", so the + * single event callback below can recover the instance id directly + * from node_id — no more separate LaunchUserData/g_launchEntries + * bookkeeping to keep in sync with the menu's contents. */ -static void on_show_clicked(void* /*ud*/) +static QByteArray build_tray_menu_json() { - if (g_ctx) - g_ctx->main_window_show(g_ctx->module_handle); + QJsonObject openItem{{"type", "button"}, + {"id", "open"}, + {"props", QJsonObject{{"label", "Open MeshMC"}}}}; + QJsonObject hideItem{{"type", "button"}, + {"id", "hide"}, + {"props", QJsonObject{{"label", "Hide window"}}}}; + QJsonObject sep{{"type", "separator"}}; + + QJsonArray launchChildren; + if (g_ctx) { + const int total = g_ctx->instance_count(g_ctx->module_handle); + int shown = 0; + for (int i = 0; i < total && shown < MAX_INSTANCE_ENTRIES; ++i) { + const char* id = g_ctx->instance_get_id(g_ctx->module_handle, i); + if (!id) + continue; + const std::string idCopy = id; + const char* name = + g_ctx->instance_get_name(g_ctx->module_handle, idCopy.c_str()); + const QString label = name ? QString::fromUtf8(name) + : QString::fromStdString(idCopy); + launchChildren.append(QJsonObject{ + {"type", "button"}, + {"id", QStringLiteral("launch:%1") + .arg(QString::fromStdString(idCopy))}, + {"props", QJsonObject{{"label", label}}}}); + ++shown; + } + } + if (launchChildren.isEmpty()) { + launchChildren.append(QJsonObject{ + {"type", "button"}, + {"id", "launch:none"}, + {"props", + QJsonObject{{"label", "(no instances)"}, {"enabled", false}}}}); + } + const QJsonObject launchSection{ + {"type", "section"}, + {"id", "launch"}, + {"props", QJsonObject{{"title", "Launch instance"}}}, + {"children", launchChildren}}; + + QJsonObject quitItem{{"type", "button"}, + {"id", "quit"}, + {"props", QJsonObject{{"label", "Quit MeshMC"}}}}; + + const QJsonArray items{openItem, hideItem, sep, launchSection, sep, quitItem}; + const QJsonObject doc{{"type", "mmco-tray-menu/1"}, {"items", items}}; + return QJsonDocument(doc).toJson(QJsonDocument::Compact); } -static void on_hide_clicked(void* /*ud*/) -{ - if (g_ctx) - g_ctx->main_window_hide(g_ctx->module_handle); -} +static void rebuild_tray_menu(); -static void on_quit_clicked(void* /*ud*/) +/* ── tray menu event callback ─────────────────────────────────────── */ + +static void on_tray_menu_event(void* /*ud*/, const char* /*surface_id*/, + const char* node_id, const char* event, + const char* /*value_json*/) { - /* QCoreApplication::quit() is the cleanest path — it tears down the - * event loop which in turn unwinds MeshMC's Application shutdown, - * giving PluginManager a chance to mmco_unload() us properly. */ - QMetaObject::invokeMethod(qApp, "quit", Qt::QueuedConnection); + if (!g_ctx || !node_id || !event || std::strcmp(event, "click") != 0) + return; + + const QString id = QString::fromUtf8(node_id); + if (id == QLatin1String("open")) { + g_ctx->main_window_show(g_ctx->module_handle); + } else if (id == QLatin1String("hide")) { + g_ctx->main_window_hide(g_ctx->module_handle); + } else if (id == QLatin1String("quit")) { + /* QCoreApplication::quit() is the cleanest path — it tears down + * the event loop which in turn unwinds MeshMC's Application + * shutdown, giving PluginManager a chance to mmco_unload() us + * properly. */ + QMetaObject::invokeMethod(qApp, "quit", Qt::QueuedConnection); + } else if (id.startsWith(QLatin1String("launch:"))) { + const QByteArray instId = id.mid(7).toUtf8(); + if (!instId.isEmpty()) + g_ctx->instance_launch(g_ctx->module_handle, instId.constData(), + /*online=*/1); + } } -static void on_launch_entry(void* ud) +static void rebuild_tray_menu() { - if (!g_ctx || !ud) + if (!g_ctx || !g_tray) return; - auto* data = static_cast(ud); - if (data->entryIndex < 0 || data->entryIndex >= g_launchEntries.size()) - return; - const std::string& id = g_launchEntries[data->entryIndex].id; - g_ctx->instance_launch(g_ctx->module_handle, id.c_str(), /*online=*/1); + const QByteArray json = build_tray_menu_json(); + g_ctx->tray_set_menu(g_ctx->module_handle, g_tray, json.constData(), + on_tray_menu_event, nullptr); } /* ── tray activation: left-click toggles the main window ──────────── */ @@ -218,144 +280,63 @@ static int on_main_window_close(void* /*ud*/) return 1; /* swallow → host will hide() the main window */ } -/* ── launch submenu rebuilding ────────────────────────────────────── */ - -static void rebuild_launch_submenu() -{ - if (!g_ctx || !g_launchMenu) - return; - - /* Free old per-entry user-data and clear the menu. */ - for (auto* ud : g_launchUserData) - delete ud; - g_launchUserData.clear(); - g_launchEntries.clear(); - g_ctx->tray_menu_clear(g_ctx->module_handle, g_launchMenu); - - int total = g_ctx->instance_count(g_ctx->module_handle); - int shown = 0; - for (int i = 0; i < total && shown < MAX_INSTANCE_ENTRIES; ++i) { - const char* id = g_ctx->instance_get_id(g_ctx->module_handle, i); - if (!id) - continue; - std::string idCopy = id; - const char* name = - g_ctx->instance_get_name(g_ctx->module_handle, idCopy.c_str()); - std::string nameCopy = name ? name : idCopy; - - InstanceEntry e; - e.id = idCopy; - e.name = nameCopy; - g_launchEntries.push_back(e); - - auto* ud = new LaunchUserData{shown}; - g_launchUserData.push_back(ud); - - g_ctx->tray_menu_add_action(g_ctx->module_handle, g_launchMenu, - nameCopy.c_str(), /*icon=*/nullptr, - on_launch_entry, ud); - ++shown; - } - - if (shown == 0) { - /* Add a disabled placeholder so the submenu is never empty. */ - void* placeholder = g_ctx->tray_menu_add_action( - g_ctx->module_handle, g_launchMenu, "(no instances)", nullptr, - nullptr, nullptr); - if (placeholder) - g_ctx->tray_menu_action_set_enabled(g_ctx->module_handle, - placeholder, 0); - } -} - -/* ── Settings UI injection ────────────────────────────────────────── */ - -/* Walk qApp->allWidgets() for the MeshMCPage (the first tab on the - * global Settings dialog). Same pattern BackupSystem and GitVersioning - * use — the page is rebuilt every time the dialog opens, so we have - * to re-find it and re-inject after every globalSettingsAboutToOpen. +/* ── Settings UI: one ABI 5 GLOBAL_SETTINGS surface ───────────────── * * - * The injected checkbox is wired to APPLICATION->settings() - * "plugin.system_tray.Enabled". Flipping it doesn't tear down or - * re-initialise the plugin live (Qt has no graceful way to reverse - * mmco_init mid-session) — instead we explain that the change takes - * effect after restart, and on the next launcher startup mmco_init - * sees the new value and either skips itself or comes up normally. */ -static void injectCheckboxIntoMeshMCPage() + * Replaces injectCheckboxIntoMeshMCPage()'s allWidgets()/findChild walk + * against MeshMCPage's "verticalLayout_9": the host now renders this + * document as a titled section inside its own "Plugins" page every + * time the global Settings dialog opens, from whatever document is + * currently stored for this surface — so, unlike the old pattern, + * this only needs to be created ONCE (here, from mmco_init()), not + * re-injected via MMCO_HOOK_GLOBAL_SETTINGS_ABOUT_TO_OPEN on every + * open. */ + +static void on_settings_surface_event(void* /*ud*/, const char* /*surface_id*/, + const char* node_id, const char* event, + const char* value_json) { - QWidget* meshMCPage = nullptr; - for (auto* w : qApp->allWidgets()) { - if (w->objectName() == QStringLiteral("MeshMCPage")) { - meshMCPage = w; - break; - } - } - if (!meshMCPage) + if (!g_ctx || !node_id || !event) return; - - auto* layout = - meshMCPage->findChild(QStringLiteral("verticalLayout_9")); - if (!layout) + if (QString::fromUtf8(node_id) != QLatin1String("enabled")) + return; + if (std::strcmp(event, "change") != 0) return; - auto* groupBox = new QGroupBox(QObject::tr("System Tray")); - groupBox->setObjectName(QStringLiteral("systemTrayGroupBox")); - auto* gl = new QVBoxLayout(groupBox); - - g_enabledCheckbox = new QCheckBox( - QObject::tr("Show MeshMC system tray icon (Restart required.)"), - groupBox); - g_enabledCheckbox->setObjectName(QStringLiteral("systemTrayEnabledCheck")); - g_enabledCheckbox->setToolTip(QObject::tr( - "When on, MeshMC keeps a persistent tray icon with quick-launch " - "shortcuts and an optional minimise-to-tray close handler.\n\n" - "Toggle takes effect after restarting MeshMC.")); - gl->addWidget(g_enabledCheckbox); - - int spacerIdx = layout->count() - 1; - layout->insertWidget(spacerIdx, groupBox); - - bool current = false; - if (g_ctx) { - const char* v = g_ctx->app_setting_get(g_ctx->module_handle, - SETTING_GLOBAL_ENABLED); - if (v) { - QString s = QString::fromUtf8(v).trimmed().toLower(); - current = s == QLatin1String("1") || s == QLatin1String("true") || - s == QLatin1String("yes") || s == QLatin1String("on"); - } - } - g_enabledCheckbox->setChecked(current); - - QObject::connect( - g_enabledCheckbox, &QCheckBox::toggled, g_guard, [](bool checked) { - if (!g_ctx) - return; - g_ctx->app_setting_set(g_ctx->module_handle, SETTING_GLOBAL_ENABLED, - checked ? "1" : "0"); - settingSetBool("enabled", checked); - }); + const bool checked = value_json && std::strcmp(value_json, "true") == 0; + g_ctx->app_setting_set(g_ctx->module_handle, SETTING_GLOBAL_ENABLED, + checked ? "1" : "0"); + settingSetBool("enabled", checked); } -/* Hook handler for MMCO_HOOK_GLOBAL_SETTINGS_ABOUT_TO_OPEN — replaces - * the legacy direct connect to Application::globalSettingsAboutToOpen. */ -static int on_global_settings_about_to_open(void*, uint32_t, void*, void*) +static void create_settings_surface(bool currentlyEnabled) { - g_enabledCheckbox = nullptr; - QTimer::singleShot(0, qApp, injectCheckboxIntoMeshMCPage); - return 0; + if (!g_ctx) + return; + const QJsonObject doc{ + {"type", "mmco-ui/1"}, + {"root", + QJsonObject{ + {"type", "toggle"}, + {"id", "enabled"}, + {"props", + QJsonObject{ + {"label", "Show MeshMC system tray icon (Restart required.)"}, + {"value", currentlyEnabled}, + {"enabled", true}}}}}}; + const QByteArray json = QJsonDocument(doc).toJson(QJsonDocument::Compact); + g_settingsSurface = g_ctx->ui_surface_create( + g_ctx->module_handle, MMCO_UI_ANCHOR_GLOBAL_SETTINGS, nullptr, + "System Tray", nullptr, json.constData(), on_settings_surface_event, + nullptr); } +/* ── hooks ────────────────────────────────────────────────────────── */ + static int on_app_initialized(void*, uint32_t, void*, void*) { - /* Re-injection is now triggered via the hook above; this handler - * stays around as a placeholder so we can wire it up next to the - * other APP_INITIALIZED-dependent state if needed. */ return 0; } -/* ── hooks ────────────────────────────────────────────────────────── */ - static int on_ui_main_ready(void* /*mh*/, uint32_t /*hook_id*/, void* /*payload*/, void* /*ud*/) { @@ -368,22 +349,23 @@ static int on_ui_main_ready(void* /*mh*/, uint32_t /*hook_id*/, g_ctx->main_window_install_close_filter(g_ctx->module_handle, on_main_window_close, nullptr); - /* Refresh the submenu now that the UI is up — instance list is ready. */ - rebuild_launch_submenu(); + /* Refresh the tray menu now that the UI is up — instance list is + * ready. */ + rebuild_tray_menu(); return 0; } static int on_instance_created(void* /*mh*/, uint32_t /*hook_id*/, void* /*payload*/, void* /*ud*/) { - rebuild_launch_submenu(); + rebuild_tray_menu(); return 0; } static int on_instance_removed(void* /*mh*/, uint32_t /*hook_id*/, void* /*payload*/, void* /*ud*/) { - rebuild_launch_submenu(); + rebuild_tray_menu(); return 0; } @@ -402,9 +384,9 @@ MMCO_EXPORT int mmco_init(MMCOContext* ctx) return 0; } - /* Lifetime anchor for our Qt connections (settings-page injection, - * checkbox toggled signal). We intentionally never delete this; - * Qt may still have queued events targeting it at shutdown. */ + /* Lifetime anchor for our Qt connections. We intentionally never + * delete this; Qt may still have queued events targeting it at + * shutdown. */ g_guard = new QObject(); /* Mirror the plugin-local "enabled" key onto a launcher-wide @@ -432,16 +414,17 @@ MMCO_EXPORT int mmco_init(MMCOContext* ctx) /* Re-sync the plugin-local copy so existing call-sites see the * canonical answer. */ settingSetBool("enabled", globalEnabled); + + /* The settings checkbox is offered regardless of whether the tray + * itself is currently enabled — it is the only way for the user to + * flip it back on. */ + create_settings_surface(globalEnabled); + ctx->hook_register(ctx->module_handle, MMCO_HOOK_APP_INITIALIZED, + on_app_initialized, nullptr); + if (!globalEnabled) { MMCO_LOG(ctx, "SystemTray: disabled via global setting; idle " "(re-enable from Settings → MeshMC)."); - /* Still wire up the hook so we can inject the checkbox — the - * user needs a way to flip it back on. */ - ctx->hook_register(ctx->module_handle, MMCO_HOOK_APP_INITIALIZED, - on_app_initialized, nullptr); - ctx->hook_register(ctx->module_handle, - MMCO_HOOK_GLOBAL_SETTINGS_ABOUT_TO_OPEN, - on_global_settings_about_to_open, nullptr); return 0; } @@ -484,9 +467,8 @@ MMCO_EXPORT int mmco_init(MMCOContext* ctx) return 0; } - /* Build the menu. - * - * Layout (top → bottom, the way most launchers do it): + /* + * Menu layout (top → bottom, the way most launchers do it): * Open MeshMC ← primary action, picks Show or Hide * Hide window * ───────────────────── @@ -497,39 +479,15 @@ MMCO_EXPORT int mmco_init(MMCOContext* ctx) * ───────────────────── * Quit MeshMC * - * The instance list is its own submenu so refreshing it on - * INSTANCE_CREATED/REMOVED never touches Show/Hide/Quit — and so - * Wayland's StatusNotifierItem implementation doesn't have to - * cope with a long flat menu of unknown length. */ - g_menu = ctx->tray_menu_create(ctx->module_handle); - - g_showAction = - ctx->tray_menu_add_action(ctx->module_handle, g_menu, "Open MeshMC", - nullptr, on_show_clicked, nullptr); - g_hideAction = - ctx->tray_menu_add_action(ctx->module_handle, g_menu, "Hide window", - nullptr, on_hide_clicked, nullptr); - ctx->tray_menu_add_separator(ctx->module_handle, g_menu); - - g_launchMenu = ctx->tray_menu_add_submenu(ctx->module_handle, g_menu, - "Launch instance", nullptr); - - ctx->tray_menu_add_separator(ctx->module_handle, g_menu); - g_quitAction = - ctx->tray_menu_add_action(ctx->module_handle, g_menu, "Quit MeshMC", - nullptr, on_quit_clicked, nullptr); - - ctx->tray_set_menu(ctx->module_handle, g_tray, g_menu); + * Built once as a JSON doc (build_tray_menu_json()) and re-issued + * via tray_set_menu() on every INSTANCE_CREATED/REMOVED so the + * "Launch instance" submenu never goes stale. */ + rebuild_tray_menu(); ctx->tray_set_activation_cb(ctx->module_handle, g_tray, on_tray_activated, nullptr); ctx->tray_set_visible(ctx->module_handle, g_tray, 1); /* Hooks. */ - ctx->hook_register(ctx->module_handle, MMCO_HOOK_APP_INITIALIZED, - on_app_initialized, nullptr); - ctx->hook_register(ctx->module_handle, - MMCO_HOOK_GLOBAL_SETTINGS_ABOUT_TO_OPEN, - on_global_settings_about_to_open, nullptr); ctx->hook_register(ctx->module_handle, MMCO_HOOK_UI_MAIN_READY, on_ui_main_ready, nullptr); ctx->hook_register(ctx->module_handle, MMCO_HOOK_INSTANCE_CREATED, @@ -546,20 +504,12 @@ MMCO_EXPORT void mmco_unload() if (g_ctx) MMCO_LOG(g_ctx, "SystemTray unloading."); - for (auto* ud : g_launchUserData) - delete ud; - g_launchUserData.clear(); - g_launchEntries.clear(); - - /* PluginManager will sweep up the tray/menu/actions/close-filter on - * its own — see releaseTrayResourcesForModule(). We just drop our - * raw handles so we never touch them again. */ + /* PluginManager will sweep up the tray/menu/surface/close-filter on + * its own — see releaseTrayResourcesForModule() / + * releaseSurfacesForModule(). We just drop our raw handles so we + * never touch them again. */ g_tray = nullptr; - g_menu = nullptr; - g_launchMenu = nullptr; - g_showAction = nullptr; - g_hideAction = nullptr; - g_quitAction = nullptr; + g_settingsSurface = nullptr; g_ctx = nullptr; } diff --git a/launcher/plugin/plugins/staging/ErrorOracle/AnalysisPage.cpp b/launcher/plugin/plugins/staging/ErrorOracle/AnalysisPage.cpp index 0378771a..1b0808e3 100644 --- a/launcher/plugin/plugins/staging/ErrorOracle/AnalysisPage.cpp +++ b/launcher/plugin/plugins/staging/ErrorOracle/AnalysisPage.cpp @@ -5,11 +5,12 @@ #include "LogIngester.h" #include "LearningStore.h" -#include -#include +#include namespace { + constexpr int kModalResultBufSize = 4096; + const char* severityName(Severity s) { switch (s) { @@ -22,78 +23,97 @@ namespace return "low"; } } + + /* Inverse of PluginUiRenderer's jsonQuoteString: unwraps a bare JSON + * scalar (as delivered in MMCOUiEventCallback's value_json for + * "select"/"activate" events) back into a plain QString. */ + QString jsonStringValue(const QString& valueJson) + { + if (valueJson.isEmpty()) + return QString(); + const QByteArray wrapped = "[" + valueJson.toUtf8() + "]"; + QJsonParseError err{}; + const QJsonDocument jd = QJsonDocument::fromJson(wrapped, &err); + if (err.error != QJsonParseError::NoError || !jd.isArray() || + jd.array().isEmpty()) + return QString(); + return jd.array().first().toString(); + } + + /* The promote-to-rule prompt: three text fields (title, optional + * regex pattern, advice) plus a Save/Cancel button row, run + * through ui_modal_run. Replaces the QDialog the QWidget-era page + * used to build directly — the page has no QWidget of its own to + * parent a dialog to any more. The node set has no multi-line text + * editor, so the advice field is a single-line text_field (a + * behavioural narrowing from the old QTextEdit — long advice text + * still works, it just doesn't wrap on screen while typing). */ + QByteArray buildPromoteDoc(const QString& fingerprint, const QString& sampleLine) + { + const QJsonObject info{ + {"type", "text"}, + {"id", "info"}, + {"props", + QJsonObject{ + {"format", "markdown"}, + {"text", QObject::tr("Signature: `%1`\n\nSample: `%2`") + .arg(fingerprint, sampleLine)}}}}; + const QJsonObject titleField{ + {"type", "text_field"}, + {"id", "title"}, + {"props", + QJsonObject{{"label", QObject::tr("Short title (one sentence)")}}}}; + const QJsonObject patternField{ + {"type", "text_field"}, + {"id", "pattern"}, + {"props", + QJsonObject{ + {"label", + QObject::tr( + "Regex pattern (leave blank to derive from the sample line)")}}}}; + const QJsonObject adviceField{ + {"type", "text_field"}, + {"id", "advice"}, + {"props", + QJsonObject{{"label", QObject::tr("Remediation advice (Markdown)")}}}}; + const QJsonArray buttons{ + QJsonObject{{"type", "button"}, + {"id", "save"}, + {"props", QJsonObject{{"label", QObject::tr("Save")}}}}, + QJsonObject{{"type", "button"}, + {"id", "cancel"}, + {"props", QJsonObject{{"label", QObject::tr("Cancel")}}}}}; + const QJsonObject buttonRow{ + {"type", "row"}, {"id", "actions"}, {"children", buttons}}; + const QJsonArray children{info, titleField, patternField, adviceField, + buttonRow}; + const QJsonObject root{ + {"type", "column"}, {"id", "root"}, {"children", children}}; + const QJsonObject doc{{"type", "mmco-ui/1"}, {"root", root}}; + return QJsonDocument(doc).toJson(QJsonDocument::Compact); + } } // namespace -AnalysisPage::AnalysisPage(const QString& instanceId, - const QString& instanceRoot, RuleEngine* engine, - LearningStore* learning, QWidget* parent) - : QWidget(parent), m_instanceId(instanceId), m_instanceRoot(instanceRoot), - m_engine(engine), m_learning(learning) +AnalysisPageController::AnalysisPageController(MMCOContext* ctx, QString instanceId, + QString instanceRoot, + RuleEngine* engine, + LearningStore* learning) + : m_ctx(ctx), m_instanceId(std::move(instanceId)), + m_instanceRoot(std::move(instanceRoot)), m_engine(engine), + m_learning(learning) { - buildUi(); - runAnalysis(); } -void AnalysisPage::buildUi() +void AnalysisPageController::notify(int type, const QString& title, + const QString& message) const { - auto* root = new QVBoxLayout(this); - - m_summaryLabel = new QLabel(this); - m_summaryLabel->setWordWrap(true); - root->addWidget(m_summaryLabel); - - auto* splitter = new QSplitter(Qt::Horizontal, this); - - m_tree = new QTreeWidget(splitter); - m_tree->setHeaderLabels( - {tr("Severity"), tr("Title"), tr("Line"), tr("Score")}); - m_tree->setRootIsDecorated(false); - m_tree->setAlternatingRowColors(true); - connect(m_tree, &QTreeWidget::itemSelectionChanged, this, - &AnalysisPage::onSelectionChanged); - splitter->addWidget(m_tree); - - m_adviceView = new QTextEdit(splitter); - m_adviceView->setReadOnly(true); - splitter->addWidget(m_adviceView); - splitter->setStretchFactor(0, 1); - splitter->setStretchFactor(1, 2); - - root->addWidget(splitter, 1); - - auto* btnRow = new QHBoxLayout(); - - auto* reanalyse = new QPushButton(tr("Re-analyse"), this); - connect(reanalyse, &QPushButton::clicked, this, &AnalysisPage::onReanalyse); - btnRow->addWidget(reanalyse); - - btnRow->addStretch(); - - m_helpedBtn = new QPushButton(tr("✓ This fixed it"), this); - m_helpedBtn->setEnabled(false); - connect(m_helpedBtn, &QPushButton::clicked, this, &AnalysisPage::onHelped); - btnRow->addWidget(m_helpedBtn); - - m_didntBtn = new QPushButton(tr("✗ Didn't help"), this); - m_didntBtn->setEnabled(false); - connect(m_didntBtn, &QPushButton::clicked, this, - &AnalysisPage::onDidNotHelp); - btnRow->addWidget(m_didntBtn); - - m_promoteBtn = new QPushButton(tr("Promote unknown error → rule…"), this); - m_promoteBtn->setEnabled(false); - m_promoteBtn->setToolTip( - tr("If no rule matched but the same crash signature has appeared " - "more than once, you can teach ErrorOracle about it by writing " - "a custom title + advice.")); - connect(m_promoteBtn, &QPushButton::clicked, this, - &AnalysisPage::onPromoteNovel); - btnRow->addWidget(m_promoteBtn); - - root->addLayout(btnRow); + if (!m_ctx || !m_ctx->ui_show_message) + return; + m_ctx->ui_show_message(m_ctx->module_handle, type, title.toUtf8().constData(), + message.toUtf8().constData()); } -void AnalysisPage::runAnalysis() +void AnalysisPageController::runAnalysis() { LogIngester ing; auto bundle = ing.ingestForInstance(m_instanceRoot); @@ -106,11 +126,11 @@ void AnalysisPage::runAnalysis() m_learning->recordSeen(m.ruleId, m_instanceId); } std::sort(m_matches.begin(), m_matches.end(), - [](const Match& a, const Match& b) { - if (a.severity != b.severity) - return int(a.severity) > int(b.severity); - return a.score > b.score; - }); + [](const Match& a, const Match& b) { + if (a.severity != b.severity) + return int(a.severity) > int(b.severity); + return a.score > b.score; + }); // Compute & remember a fingerprint of the failure for the // "promote to rule" affordance. @@ -125,146 +145,271 @@ void AnalysisPage::runAnalysis() if (it.hasMatch()) m_currentSampleLine = it.captured(1).left(160); - m_tree->clear(); - for (const auto& m : m_matches) { - auto* item = new QTreeWidgetItem(m_tree); - item->setText(0, QString::fromLatin1(severityName(m.severity))); - item->setText(1, m.ruleTitle); - item->setText(2, m.line >= 0 ? QString::number(m.line) : QString("?")); - item->setText(3, QString::number(m.score, 'f', 2)); - item->setData(0, Qt::UserRole, m.ruleId); - } - m_tree->header()->setSectionResizeMode(QHeaderView::ResizeToContents); - // Record novel fingerprint if no rule fired but we have a signature. if (m_matches.isEmpty() && !m_currentFingerprint.isEmpty()) { m_learning->recordNovel(m_currentFingerprint, m_currentSampleLine, m_instanceId); m_learning->save(); - m_promoteBtn->setEnabled(true); - } else { - m_promoteBtn->setEnabled(false); } - // Summary text - QString src = bundle.sources.isEmpty() - ? tr("(no logs found)") - : bundle.sources.join(QStringLiteral(", ")); - m_summaryLabel->setText( - tr("Analysed: %1. %2 rule match(es). Crash signature: " - "%3") - .arg(src.toHtmlEscaped()) - .arg(m_matches.size()) - .arg(m_currentFingerprint.isEmpty() ? tr("(no stack trace)") - : m_currentFingerprint)); - - if (m_matches.isEmpty()) { - m_adviceView->setMarkdown( - tr("### No matching rules\n\n" - "Either the instance ran cleanly or the crash isn't in " - "ErrorOracle's rule pack yet. " - "If the same signature shows up more than once you can " - "promote it into a rule using the button below.\n")); + m_lastSources = bundle.sources; + m_selectedRuleId.clear(); +} + +QJsonArray AnalysisPageController::buildRows() const +{ + QJsonArray rows; + for (const auto& m : m_matches) { + const QJsonArray cells{ + QString::fromLatin1(severityName(m.severity)), m.ruleTitle, + m.line >= 0 ? QString::number(m.line) : QStringLiteral("?"), + QString::number(m.score, 'f', 2)}; + rows.append(QJsonObject{{"id", m.ruleId}, {"cells", cells}}); } + return rows; +} + +QString AnalysisPageController::buildSummaryText() const +{ + const QString src = m_lastSources.isEmpty() + ? QObject::tr("(no logs found)") + : m_lastSources.join(QStringLiteral(", ")); + return QObject::tr("Analysed: **%1**. %2 rule match(es). Crash signature: `%3`") + .arg(src) + .arg(m_matches.size()) + .arg(m_currentFingerprint.isEmpty() ? QObject::tr("(no stack trace)") + : m_currentFingerprint); +} + +QString AnalysisPageController::buildAdviceText(const Match& m) const +{ + return QStringLiteral("### %1\n\n%2\n\n---\n\n**Matched line:** `%3`\n") + .arg(m.ruleTitle, m.advice, m.matchedLine); +} + +QString AnalysisPageController::noMatchAdviceText() const +{ + return QObject::tr( + "### No matching rules\n\n" + "Either the instance ran cleanly or the crash isn't in ErrorOracle's " + "rule pack yet. If the same signature shows up more than once you can " + "promote it into a rule using the button below.\n"); +} + +QJsonObject AnalysisPageController::buildDocument() const +{ + const bool canPromote = m_matches.isEmpty() && !m_currentFingerprint.isEmpty(); + + const QJsonObject summaryNode{ + {"type", "text"}, + {"id", "summary"}, + {"props", + QJsonObject{{"format", "markdown"}, {"text", buildSummaryText()}}}}; + + const QJsonObject listNode{ + {"type", "list"}, + {"id", "matches"}, + {"props", + QJsonObject{ + {"columns", QJsonArray{QObject::tr("Severity"), QObject::tr("Title"), + QObject::tr("Line"), QObject::tr("Score")}}, + {"rows", buildRows()}}}}; + + const QJsonObject adviceNode{ + {"type", "text"}, + {"id", "advice"}, + {"props", + QJsonObject{ + {"format", "markdown"}, + {"text", m_matches.isEmpty() ? noMatchAdviceText() : QString()}}}}; + + const QJsonArray buttons{ + QJsonObject{{"type", "button"}, + {"id", "reanalyse"}, + {"props", QJsonObject{{"label", QObject::tr("Re-analyse")}}}}, + QJsonObject{{"type", "button"}, + {"id", "helped"}, + {"props", QJsonObject{{"label", QObject::tr("This fixed it")}, + {"enabled", false}}}}, + QJsonObject{{"type", "button"}, + {"id", "didnt_help"}, + {"props", QJsonObject{{"label", QObject::tr("Didn't help")}, + {"enabled", false}}}}, + QJsonObject{ + {"type", "button"}, + {"id", "promote"}, + {"props", + QJsonObject{{"label", QObject::tr("Promote unknown error to rule…")}, + {"enabled", canPromote}}}}}; + const QJsonObject buttonRow{ + {"type", "row"}, {"id", "actions"}, {"children", buttons}}; + + const QJsonArray rootChildren{summaryNode, listNode, adviceNode, buttonRow}; + const QJsonObject root{ + {"type", "column"}, {"id", "root"}, {"children", rootChildren}}; + return QJsonObject{{"type", "mmco-ui/1"}, {"root", root}}; +} + +void AnalysisPageController::setSummaryText(const QString& text) const +{ + if (!m_ctx || !m_surface) + return; + const QJsonObject patch{{"text", text}}; + const QByteArray json = QJsonDocument(patch).toJson(QJsonDocument::Compact); + m_ctx->ui_surface_set(m_ctx->module_handle, m_surface, "summary", + json.constData()); +} + +void AnalysisPageController::setAdviceText(const QString& text) const +{ + if (!m_ctx || !m_surface) + return; + const QJsonObject patch{{"text", text}}; + const QByteArray json = QJsonDocument(patch).toJson(QJsonDocument::Compact); + m_ctx->ui_surface_set(m_ctx->module_handle, m_surface, "advice", + json.constData()); +} + +void AnalysisPageController::pushRows() const +{ + if (!m_ctx || !m_surface) + return; + const QByteArray json = + QJsonDocument(buildRows()).toJson(QJsonDocument::Compact); + m_ctx->ui_surface_set_rows(m_ctx->module_handle, m_surface, "matches", + json.constData()); +} + +void AnalysisPageController::setNodeEnabled(const QString& nodeId, + bool enabled) const +{ + if (!m_ctx || !m_surface) + return; + const QJsonObject patch{{"enabled", enabled}}; + const QByteArray json = QJsonDocument(patch).toJson(QJsonDocument::Compact); + m_ctx->ui_surface_set(m_ctx->module_handle, m_surface, + nodeId.toUtf8().constData(), json.constData()); +} + +void AnalysisPageController::createSurface() +{ + if (!m_ctx || m_surface) + return; + + runAnalysis(); + + const QByteArray json = + QJsonDocument(buildDocument()).toJson(QJsonDocument::Compact); + m_surface = m_ctx->ui_surface_create( + m_ctx->module_handle, MMCO_UI_ANCHOR_INSTANCE_PAGE, + m_instanceId.toUtf8().constData(), + QObject::tr("Error Analysis").toUtf8().constData(), "status-bad", + json.constData(), &AnalysisPageController::eventTrampoline, this); +} + +void AnalysisPageController::destroySurface() +{ + if (!m_ctx || !m_surface) + return; + m_ctx->ui_surface_destroy(m_ctx->module_handle, m_surface); + m_surface = nullptr; } -Match AnalysisPage::selectedMatch() const +void AnalysisPageController::reloadAnalysis() { - auto items = m_tree->selectedItems(); - if (items.isEmpty()) + if (!m_ctx || !m_surface) + return; + + runAnalysis(); + + setSummaryText(buildSummaryText()); + pushRows(); + setAdviceText(m_matches.isEmpty() ? noMatchAdviceText() : QString()); + setNodeEnabled(QStringLiteral("helped"), false); + setNodeEnabled(QStringLiteral("didnt_help"), false); + setNodeEnabled(QStringLiteral("promote"), + m_matches.isEmpty() && !m_currentFingerprint.isEmpty()); +} + +Match AnalysisPageController::selectedMatch() const +{ + if (m_selectedRuleId.isEmpty()) return {}; - QString id = items.first()->data(0, Qt::UserRole).toString(); for (const auto& m : m_matches) - if (m.ruleId == id) + if (m.ruleId == m_selectedRuleId) return m; return {}; } -void AnalysisPage::onSelectionChanged() +void AnalysisPageController::onSelectionChanged(const QString& rowId) { - auto m = selectedMatch(); - bool enable = !m.ruleId.isEmpty(); - m_helpedBtn->setEnabled(enable); - m_didntBtn->setEnabled(enable); - if (!enable) - return; - QString matchLine = m.matchedLine.toHtmlEscaped(); - QString md = QStringLiteral("### %1\n\n").arg(m.ruleTitle) + m.advice + - QStringLiteral("\n\n---\n\n**Matched line:** `") + - m.matchedLine + QStringLiteral("`\n"); - m_adviceView->setMarkdown(md); + m_selectedRuleId = rowId; + Match m = selectedMatch(); + const bool enable = !m.ruleId.isEmpty(); + setNodeEnabled(QStringLiteral("helped"), enable); + setNodeEnabled(QStringLiteral("didnt_help"), enable); + if (enable) + setAdviceText(buildAdviceText(m)); + else + setAdviceText(m_matches.isEmpty() ? noMatchAdviceText() : QString()); } -void AnalysisPage::onReanalyse() +void AnalysisPageController::onReanalyseClicked() { - runAnalysis(); + reloadAnalysis(); } -void AnalysisPage::onHelped() +void AnalysisPageController::onHelpedClicked() { - auto m = selectedMatch(); - if (m.ruleId.isEmpty()) + Match m = selectedMatch(); + if (m.ruleId.isEmpty() || !m_learning) return; m_learning->recordHelped(m.ruleId, m_instanceId); m_learning->save(); - m_summaryLabel->setText(tr("Recorded: rule %1 helped on this " - "instance.") - .arg(m.ruleTitle)); + setSummaryText( + QObject::tr("Recorded: rule **%1** helped on this instance.").arg(m.ruleTitle)); } -void AnalysisPage::onDidNotHelp() +void AnalysisPageController::onDidNotHelpClicked() { - auto m = selectedMatch(); - if (m.ruleId.isEmpty()) + Match m = selectedMatch(); + if (m.ruleId.isEmpty() || !m_learning) return; m_learning->recordDidNotHelp(m.ruleId, m_instanceId); m_learning->save(); - m_summaryLabel->setText( - tr("Recorded: rule %1 did not help.").arg(m.ruleTitle)); + setSummaryText( + QObject::tr("Recorded: rule **%1** did not help.").arg(m.ruleTitle)); } -void AnalysisPage::onPromoteNovel() +void AnalysisPageController::onPromoteClicked() { - if (m_currentFingerprint.isEmpty()) + if (!m_ctx || m_currentFingerprint.isEmpty()) return; - QDialog dlg(this); - dlg.setWindowTitle(tr("Promote crash signature to a user rule")); - auto* v = new QVBoxLayout(&dlg); - v->addWidget( - new QLabel(tr("Signature: %1").arg(m_currentFingerprint))); - v->addWidget(new QLabel(tr("Sample: %1") - .arg(m_currentSampleLine.toHtmlEscaped()))); - - auto* titleEdit = new QLineEdit(&dlg); - titleEdit->setPlaceholderText(tr("Short title (one sentence)")); - v->addWidget(titleEdit); - - auto* patternEdit = new QLineEdit(&dlg); - patternEdit->setPlaceholderText( - tr("Regex pattern (leave blank to derive from the sample line)")); - v->addWidget(patternEdit); - - auto* adviceEdit = new QTextEdit(&dlg); - adviceEdit->setPlaceholderText(tr("Remediation advice in Markdown")); - v->addWidget(adviceEdit); - - auto* btnBox = new QHBoxLayout(); - btnBox->addStretch(); - auto* okBtn = new QPushButton(tr("Save"), &dlg); - auto* cancelBtn = new QPushButton(tr("Cancel"), &dlg); - btnBox->addWidget(okBtn); - btnBox->addWidget(cancelBtn); - v->addLayout(btnBox); - connect(okBtn, &QPushButton::clicked, &dlg, &QDialog::accept); - connect(cancelBtn, &QPushButton::clicked, &dlg, &QDialog::reject); - - if (dlg.exec() != QDialog::Accepted) + const QByteArray doc = + buildPromoteDoc(m_currentFingerprint, m_currentSampleLine); + + char resultBuf[kModalResultBufSize]; + const int rc = m_ctx->ui_modal_run( + m_ctx->module_handle, + QObject::tr("Promote crash signature to a user rule").toUtf8().constData(), + doc.constData(), resultBuf, sizeof(resultBuf)); + if (rc != 0) + return; /* cancelled / dialog closed */ + + QJsonParseError err{}; + const QJsonDocument jd = QJsonDocument::fromJson(QByteArray(resultBuf), &err); + if (err.error != QJsonParseError::NoError || !jd.isObject()) + return; + const QJsonObject result = jd.object(); + if (result.value(QStringLiteral("button")).toString() != QLatin1String("save")) return; - QString title = titleEdit->text().trimmed(); - QString pattern = patternEdit->text().trimmed(); - QString advice = adviceEdit->toPlainText().trimmed(); + const QJsonObject fields = result.value(QStringLiteral("fields")).toObject(); + const QString title = fields.value(QStringLiteral("title")).toString().trimmed(); + QString pattern = fields.value(QStringLiteral("pattern")).toString().trimmed(); + const QString advice = + fields.value(QStringLiteral("advice")).toString().trimmed(); if (title.isEmpty() || advice.isEmpty()) return; @@ -278,7 +423,7 @@ void AnalysisPage::onPromoteNovel() // Persist into a user rules pack we control. QString userRulesDir = - QString::fromUtf8(/* plugin will fill this in via ctx */ + QString::fromUtf8(/* set by ErrorOraclePlugin.cpp's mmco_init() */ qgetenv("MESHMC_USER_RULES_DIR")); if (userRulesDir.isEmpty()) userRulesDir = @@ -288,8 +433,8 @@ void AnalysisPage::onPromoteNovel() QString fileName = "promoted-" + m_currentFingerprint + ".json"; QFile out(QDir(userRulesDir).filePath(fileName)); if (!out.open(QIODevice::WriteOnly | QIODevice::Truncate)) { - QMessageBox::warning(this, tr("ErrorOracle"), - tr("Could not write user rule file.")); + notify(1, QObject::tr("ErrorOracle"), + QObject::tr("Could not write user rule file.")); return; } QJsonObject root; @@ -312,10 +457,44 @@ void AnalysisPage::onPromoteNovel() root["rules"] = rules; out.write(QJsonDocument(root).toJson(QJsonDocument::Indented)); - m_learning->forgetNovel(m_currentFingerprint); - m_learning->save(); + if (m_learning) { + m_learning->forgetNovel(m_currentFingerprint); + m_learning->save(); + } + + notify(0, QObject::tr("ErrorOracle"), + QObject::tr("Saved user rule. Click Re-analyse to load it.")); + setNodeEnabled(QStringLiteral("promote"), false); +} - QMessageBox::information( - this, tr("ErrorOracle"), - tr("Saved user rule. Click Re-analyse to load it.")); +void AnalysisPageController::eventTrampoline(void* user_data, + const char* /*surface_id*/, + const char* node_id, const char* event, + const char* value_json) +{ + auto* self = static_cast(user_data); + if (!self || !node_id || !event) + return; + self->handleEvent(QString::fromUtf8(node_id), QString::fromUtf8(event), + value_json ? QString::fromUtf8(value_json) : QString()); +} + +void AnalysisPageController::handleEvent(const QString& nodeId, + const QString& event, + const QString& valueJson) +{ + if (event == QLatin1String("click")) { + if (nodeId == QLatin1String("reanalyse")) + onReanalyseClicked(); + else if (nodeId == QLatin1String("helped")) + onHelpedClicked(); + else if (nodeId == QLatin1String("didnt_help")) + onDidNotHelpClicked(); + else if (nodeId == QLatin1String("promote")) + onPromoteClicked(); + } else if ((event == QLatin1String("select") || + event == QLatin1String("activate")) && + nodeId == QLatin1String("matches")) { + onSelectionChanged(jsonStringValue(valueJson)); + } } diff --git a/launcher/plugin/plugins/staging/ErrorOracle/AnalysisPage.h b/launcher/plugin/plugins/staging/ErrorOracle/AnalysisPage.h index 9bc7e54a..cf5fee91 100644 --- a/launcher/plugin/plugins/staging/ErrorOracle/AnalysisPage.h +++ b/launcher/plugin/plugins/staging/ErrorOracle/AnalysisPage.h @@ -1,10 +1,22 @@ /* SPDX-FileCopyrightText: 2026 Project Tick * SPDX-License-Identifier: Apache-2.0 * - * AnalysisPage — instance page that runs the rule engine over the - * instance's most recent log, shows ranked suggestions, and lets the - * user mark which suggestion actually fixed the problem so the - * LearningStore can re-rank future suggestions. + * AnalysisPageController — ABI 5 declarative-UI controller for the + * per-instance "Error Analysis" page. + * + * This replaces the former AnalysisPage (a QWidget + BasePage + * subclass: match tree, advice text, summary label, action buttons), + * migrated exactly the way GitVersioningPage was migrated in commit + * aa2c3e9c — see GitVersioning/GitVersioningPage.h/.cpp for the + * reference shape this mirrors. There is no QWidget here at all: the + * actual widget tree is built by the host's PluginUiRenderer from the + * "mmco-ui/1" JSON document this class maintains, and mounted fresh + * every time the instance window's page list is rebuilt (see + * PluginManager::createInstancePages). This object just owns the + * MMCO_UI_ANCHOR_INSTANCE_PAGE surface handle, the cached rule-engine + * matches, and the current row selection, and pushes updates through + * ui_surface_set / ui_surface_set_rows in response to events delivered + * through MMCOUiEventCallback. */ #pragma once @@ -12,53 +24,83 @@ #include "plugin/sdk/mmco_cxx_sdk.hpp" #include "RuleEngine.h" -class QTextEdit; +class LearningStore; -class AnalysisPage : public QWidget, public BasePage +class AnalysisPageController { - Q_OBJECT public: - AnalysisPage(const QString& instanceId, const QString& instanceRoot, - RuleEngine* engine, class LearningStore* learning, - QWidget* parent = nullptr); + AnalysisPageController(MMCOContext* ctx, QString instanceId, QString instanceRoot, + RuleEngine* engine, LearningStore* learning); + + /* Builds the initial document from a fresh analysis run and + * registers the MMCO_UI_ANCHOR_INSTANCE_PAGE surface. Must be + * called once per instance, before that instance's page list can + * be requested — see ErrorOraclePlugin.cpp's + * MMCO_HOOK_UI_MAIN_READY / MMCO_HOOK_INSTANCE_CREATED handlers. */ + void createSurface(); - QString id() const override - { - return QStringLiteral("error-oracle"); - } - QString displayName() const override - { - return QObject::tr("Error Analysis"); - } - QIcon icon() const override - { - return QIcon::fromTheme(QStringLiteral("status-bad")); - } + /* Tears the surface down early — used when the instance itself is + * removed while the plugin stays loaded. Safe to call more than + * once. NOT needed on plugin unload: the host tears down every + * surface a module still owns automatically before mmco_unload() + * runs (see PluginManager::releaseSurfacesForModule) — calling + * this afterwards would touch an already-freed handle. */ + void destroySurface(); - private slots: - void onReanalyse(); - void onHelped(); - void onDidNotHelp(); - void onPromoteNovel(); - void onSelectionChanged(); + /* Re-runs the rule engine over the instance's latest log/crash + * report and pushes a fresh summary + row set + advice text to + * the surface. Called from the Re-analyse button's click event + * and — to keep the page as up to date as the old per-open + * BasePage reconstruction used to be — every time this instance's + * page list is about to be rebuilt (see ErrorOraclePlugin.cpp's + * MMCO_HOOK_UI_INSTANCE_PAGES handler). */ + void reloadAnalysis(); private: - void buildUi(); - void runAnalysis(); + void handleEvent(const QString& nodeId, const QString& event, + const QString& valueJson); + static void eventTrampoline(void* user_data, const char* surface_id, + const char* node_id, const char* event, + const char* value_json); + + void onReanalyseClicked(); + void onHelpedClicked(); + void onDidNotHelpClicked(); + void onPromoteClicked(); + void onSelectionChanged(const QString& rowId); + Match selectedMatch() const; + /* Ingests the instance's latest log, runs the rule engine, scores + * + sorts the matches, and records a novel fingerprint if nothing + * matched. Pure state update — callers push the result to the + * surface themselves (createSurface() / reloadAnalysis()). */ + void runAnalysis(); + + /* Info/warning toast, routed through the host's ui_show_message + * (unchanged since ABI 2 — a single opaque host dialog, not a + * persistent widget, so ABI 5 left it as-is). type: 0=info, + * 1=warning. */ + void notify(int type, const QString& title, const QString& message) const; + QJsonObject buildDocument() const; + QJsonArray buildRows() const; + QString buildSummaryText() const; + QString buildAdviceText(const Match& m) const; + QString noMatchAdviceText() const; + void setSummaryText(const QString& text) const; + void setAdviceText(const QString& text) const; + void pushRows() const; + void setNodeEnabled(const QString& nodeId, bool enabled) const; + + MMCOContext* m_ctx = nullptr; QString m_instanceId; QString m_instanceRoot; RuleEngine* m_engine = nullptr; - class LearningStore* m_learning = nullptr; + LearningStore* m_learning = nullptr; QList m_matches; + QStringList m_lastSources; /* log/crash-report paths from the last ingest */ QString m_currentFingerprint; QString m_currentSampleLine; - - QTreeWidget* m_tree = nullptr; - QTextEdit* m_adviceView = nullptr; - QLabel* m_summaryLabel = nullptr; - QPushButton* m_helpedBtn = nullptr; - QPushButton* m_didntBtn = nullptr; - QPushButton* m_promoteBtn = nullptr; + QString m_selectedRuleId; /* ruleId of the selected match row, or empty */ + void* m_surface = nullptr; }; diff --git a/launcher/plugin/plugins/staging/ErrorOracle/CMakeLists.txt b/launcher/plugin/plugins/staging/ErrorOracle/CMakeLists.txt index 97cb55f1..d7fc0769 100644 --- a/launcher/plugin/plugins/staging/ErrorOracle/CMakeLists.txt +++ b/launcher/plugin/plugins/staging/ErrorOracle/CMakeLists.txt @@ -14,7 +14,7 @@ if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) set(MeshMC_QT_VERSION_MAJOR "6" CACHE STRING "Major Qt version to build against (5 or 6)") set(QT_VERSION_MAJOR "${MeshMC_QT_VERSION_MAJOR}") - find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Core Widgets Gui Network) + find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Core Gui Network) find_package(MeshMC_SDK REQUIRED) set(MESHMC_PLUGIN_STAGING_DIR "${CMAKE_BINARY_DIR}/mmcmodules" CACHE PATH diff --git a/launcher/plugin/plugins/staging/ErrorOracle/ErrorOraclePlugin.cpp b/launcher/plugin/plugins/staging/ErrorOracle/ErrorOraclePlugin.cpp index e52552f4..8fee1474 100644 --- a/launcher/plugin/plugins/staging/ErrorOracle/ErrorOraclePlugin.cpp +++ b/launcher/plugin/plugins/staging/ErrorOracle/ErrorOraclePlugin.cpp @@ -6,6 +6,29 @@ * page, and on every INSTANCE_POST_LAUNCH with a crash, automatically * pops a system-tray notification telling the user "ErrorOracle found * %d suggestion(s) for the last crash." + * + * ABI 5 — declarative UI surfaces (plugin-abi5-spec.md §4 step 3). + * The "Error Analysis" page is now an mmco-ui/1 document handed to + * ui_surface_create; the host renders and owns the actual widget + * tree. There is no QWidget/BasePage/page_list_handle append anywhere + * in this plugin any more — migrated the same way GitVersioning's + * instance page was migrated in commit aa2c3e9c (see + * GitVersioning/GitVersioningPlugin.cpp for the reference shape): + * UI_MAIN_READY — instances loaded by the host before this + * plugin's mmco_init() ran get their surface + * created here. + * INSTANCE_CREATED — create the surface for a newly added instance. + * INSTANCE_REMOVED — destroy it, freeing the AnalysisPageController. + * UI_INSTANCE_PAGES — fires every time an instance window's page + * list is (re)built, *before* + * PluginManager::createInstancePages() reads + * our surface's document. We don't append + * anything to page_list_handle any more (that + * was the pre-ABI-5 mechanism) — we just reuse + * this moment to re-run the analysis, so the + * page is as current as the old + * reconstruct-a-fresh-BasePage-per-open model + * used to be. */ #include "plugin/sdk/mmco_cxx_sdk.hpp" @@ -14,6 +37,11 @@ #include "LogIngester.h" #include "AnalysisPage.h" +/* QCoreApplication::applicationDirPath() (builtInRulesDir(), below) used + * to come in transitively via the SDK header's ; ABI 5 + * dropped that include (see mmco_cxx_sdk.hpp), so this plugin now + * includes the QtCore-only QCoreApplication header itself. */ +#include #include MMCO_DEFINE_MODULE("ErrorOracle", "1.0.0", "Project Tick", @@ -25,6 +53,11 @@ namespace MMCOContext* g_ctx = nullptr; RuleEngine* g_engine = nullptr; LearningStore* g_learning = nullptr; + /* Raw owning pointers, not unique_ptr: QHash is implicitly shared + * (copy-on-write), so its value type must stay copyable even with + * a single owner — same reasoning as GitVersioningPlugin.cpp's + * QHash. */ + QHash g_instances; QString builtInRulesDir() { @@ -112,19 +145,87 @@ namespace } } // namespace -static int on_instance_pages(void*, uint32_t, void* payload, void*) +/* ---- Per-instance lifecycle ------------------------------------------ */ + +static void createInstanceUi(const QString& instanceId, const QString& instanceRoot) { - auto* evt = static_cast(payload); - if (!evt || !evt->page_list_handle || !evt->instance_id) + if (!g_ctx || instanceId.isEmpty() || g_instances.contains(instanceId)) + return; + auto* controller = new AnalysisPageController(g_ctx, instanceId, instanceRoot, + g_engine, g_learning); + controller->createSurface(); + g_instances.insert(instanceId, controller); +} + +static void destroyInstanceUi(const QString& instanceId) +{ + auto it = g_instances.find(instanceId); + if (it == g_instances.end()) + return; + AnalysisPageController* controller = it.value(); + controller->destroySurface(); + g_instances.erase(it); + delete controller; +} + +/* Instances that already existed when this module loaded aren't known + * until the instance list has actually been populated — mirrors + * GitVersioning's identical reasoning for building its per-instance + * surfaces here instead of in mmco_init(). */ +static int on_ui_main_ready(void*, uint32_t, void*, void*) +{ + if (!g_ctx) return 0; + const int total = g_ctx->instance_count(g_ctx->module_handle); + for (int i = 0; i < total; ++i) { + /* Copy before the next call: the host returns strings in one + * per-module buffer, which instance_get_path() overwrites. */ + const char* rawId = g_ctx->instance_get_id(g_ctx->module_handle, i); + if (!rawId) + continue; + const QByteArray id(rawId); + const char* path = + g_ctx->instance_get_path(g_ctx->module_handle, id.constData()); + createInstanceUi(QString::fromUtf8(id), + path ? QString::fromUtf8(path) : QString()); + } + return 0; +} - auto* pages = static_cast*>(evt->page_list_handle); +static int on_instance_created(void*, uint32_t, void* payload, void*) +{ + auto* info = static_cast(payload); + if (!info || !info->instance_id) + return 0; + createInstanceUi(QString::fromUtf8(info->instance_id), + info->instance_path ? QString::fromUtf8(info->instance_path) + : QString()); + return 0; +} - const QString instId = QString::fromUtf8(evt->instance_id); - const QString instRoot = - evt->instance_path ? QString::fromUtf8(evt->instance_path) : QString(); +static int on_instance_removed(void*, uint32_t, void* payload, void*) +{ + auto* info = static_cast(payload); + if (!info || !info->instance_id) + return 0; + destroyInstanceUi(QString::fromUtf8(info->instance_id)); + return 0; +} - pages->append(new AnalysisPage(instId, instRoot, g_engine, g_learning)); +/* ABI 5: no BasePage is appended here any more — the INSTANCE_PAGE + * surface itself is what PluginManager::createInstancePages() renders, + * reading whatever document our controller last pushed. We reuse this + * "page list about to be (re)built" moment to refresh the analysis one + * more time, so the page is as current as the old per-open BasePage + * reconstruction used to be. */ +static int on_instance_pages(void*, uint32_t, void* payload, void*) +{ + auto* evt = static_cast(payload); + if (!evt || !evt->instance_id) + return 0; + auto it = g_instances.find(QString::fromUtf8(evt->instance_id)); + if (it != g_instances.end()) + it.value()->reloadAnalysis(); return 0; } @@ -194,16 +295,17 @@ MMCO_EXPORT int mmco_init(MMCOContext* ctx) MMCO_LOG(ctx, msg.constData()); } + ctx->hook_register(ctx->module_handle, MMCO_HOOK_UI_MAIN_READY, + on_ui_main_ready, nullptr); + ctx->hook_register(ctx->module_handle, MMCO_HOOK_INSTANCE_CREATED, + on_instance_created, nullptr); + ctx->hook_register(ctx->module_handle, MMCO_HOOK_INSTANCE_REMOVED, + on_instance_removed, nullptr); ctx->hook_register(ctx->module_handle, MMCO_HOOK_UI_INSTANCE_PAGES, on_instance_pages, nullptr); ctx->hook_register(ctx->module_handle, MMCO_HOOK_INSTANCE_POST_LAUNCH, on_post_launch, nullptr); - ctx->ui_register_instance_action(ctx->module_handle, "Error Analysis", - "Analyse the last crash with the " - "ErrorOracle rule engine", - "status-bad", "error-oracle"); - MMCO_LOG(ctx, "ErrorOracle ready."); return 0; } @@ -214,6 +316,13 @@ MMCO_EXPORT void mmco_unload() MMCO_LOG(g_ctx, "ErrorOracle unloading."); if (g_learning) g_learning->save(); + /* Every surface this module still owns was already torn down by + * PluginManager::releaseSurfacesForModule() before this call — see + * GitVersioningPageController::destroySurface()'s comment. We only + * need to free our own heap state here, never touch a surface + * handle again. */ + qDeleteAll(g_instances); + g_instances.clear(); delete g_engine; g_engine = nullptr; delete g_learning; diff --git a/launcher/plugin/sdk/CMakeLists.txt b/launcher/plugin/sdk/CMakeLists.txt index 05e31ca6..7934918b 100644 --- a/launcher/plugin/sdk/CMakeLists.txt +++ b/launcher/plugin/sdk/CMakeLists.txt @@ -58,7 +58,6 @@ target_include_directories(MeshMC_sdk INTERFACE target_link_libraries(MeshMC_sdk INTERFACE Qt${QT_VERSION_MAJOR}::Core - Qt${QT_VERSION_MAJOR}::Widgets Qt${QT_VERSION_MAJOR}::Gui Qt${QT_VERSION_MAJOR}::Network ) @@ -92,7 +91,7 @@ set(SDK_LAUNCHER_HEADERS ${CMAKE_SOURCE_DIR}/launcher/DefaultVariable.h ${CMAKE_SOURCE_DIR}/launcher/Exception.h ${CMAKE_SOURCE_DIR}/launcher/InstanceList.h - ${CMAKE_SOURCE_DIR}/launcher/JavaCommon.h + ${CMAKE_SOURCE_DIR}/launcher/ui/JavaCommon.h ${CMAKE_SOURCE_DIR}/launcher/MMCZip.h ${CMAKE_SOURCE_DIR}/launcher/MessageLevel.h ${CMAKE_SOURCE_DIR}/launcher/ProblemProvider.h @@ -283,21 +282,16 @@ install(FILES DESTINATION "${SDK_INSTALL_DIR}/tasks" ) -# ui/pages/ -install(FILES - ${CMAKE_SOURCE_DIR}/launcher/ui/pages/BasePage.h - ${CMAKE_SOURCE_DIR}/launcher/ui/pages/BasePageContainer.h - DESTINATION "${SDK_INSTALL_DIR}/ui/pages" -) -install(FILES - ${CMAKE_SOURCE_DIR}/launcher/ui/pages/instance/InstanceSettingsPage.h - DESTINATION "${SDK_INSTALL_DIR}/ui/pages/instance" -) -install(FILES - ${CMAKE_SOURCE_DIR}/launcher/ui/dialogs/CustomMessageBox.h - ${CMAKE_SOURCE_DIR}/launcher/ui/dialogs/ProgressDialog.h - DESTINATION "${SDK_INSTALL_DIR}/ui/dialogs" -) +# ui/pages/, ui/pages/instance/, ui/dialogs/ — NOT installed any more +# (ABI 5 / plugin-abi5-spec.md §4 step 3). BasePage.h's declaration is +# copied verbatim, header-only, into mmco_cxx_sdk.hpp so out-of-tree +# plugins that still subclass the legacy BasePage pattern don't need +# this file at all; BasePageContainer is only ever forward-declared +# there (a plugin never derefs it). InstanceSettingsPage.h, +# CustomMessageBox.h and ProgressDialog.h are not included by +# mmco_cxx_sdk.hpp or by any in-tree plugin (grepped +# launcher/plugin/plugins/ and launcher/plugin/sdk/ — no hits outside +# comments), so nothing reachable from the SDK needs them installed. # updater/ install(FILES diff --git a/launcher/plugin/sdk/mmco_c_sdk.h b/launcher/plugin/sdk/mmco_c_sdk.h index c40b3a6a..8c58c914 100644 --- a/launcher/plugin/sdk/mmco_c_sdk.h +++ b/launcher/plugin/sdk/mmco_c_sdk.h @@ -84,7 +84,7 @@ extern "C" { #define MMCO_MAGIC 0x4D4D434F #define MMCO_VERSION "10.0.0" -#define MMCO_ABI_VERSION 4 +#define MMCO_ABI_VERSION 5 #define MMCO_EXTENSION ".mmco" #define MMCO_FLAG_NONE 0x00000000 #define MMCO_TRAILER_MAGIC 0x53434D4D /* ASCII "MMCS" — see MMCOFormat.h */ @@ -355,8 +355,43 @@ typedef void (*MMCOHttpCallback)(void* user_data, int status_code, typedef void (*MMCOMenuActionCallback)(void* user_data); typedef void (*MMCODirEntryCallback)(void* user_data, const char* entry_name, int is_dir); -typedef void (*MMCOButtonCallback)(void* user_data); -typedef void (*MMCOTreeSelectionCallback)(void* user_data, int row); +/* + * MMCOUiEventCallback — ABI 5. Single event callback shape for every + * declarative UI surface (ui_surface_create) and for the declarative + * tray menu (tray_set_menu). + * + * surface_id — stable string identifying which surface/menu this + * event came from (a plugin with several surfaces + * sharing one callback distinguishes them by this). + * For tray-menu events this is always "tray". + * node_id — the plugin-assigned id of the node that fired. + * event — "click" (button/link/menu item), "change" (toggle/ + * text_field/number_field/choice — value_json is the + * new value), or "select"/"activate" (list row — + * value_json is the row id). + * value_json — event-specific payload, or "" if not applicable. + */ +typedef void (*MMCOUiEventCallback)(void* user_data, const char* surface_id, + const char* node_id, const char* event, + const char* value_json); + +/* + * MMCOUiAnchor — ABI 5. Where a declarative UI surface (ui_surface_create) + * is displayed: + * + * MMCO_UI_ANCHOR_GLOBAL_SETTINGS — stacked as a titled section inside + * the host's single "Plugins" page in the global Settings dialog. + * MMCO_UI_ANCHOR_INSTANCE_PAGE — its own page in the instance window + * (anchor_context = instance id). + * MMCO_UI_ANCHOR_INSTANCE_SETTINGS — stacked as a titled section inside + * a "Plugins" group on that instance's Settings page + * (anchor_context = instance id). + */ +enum MMCOUiAnchor { + MMCO_UI_ANCHOR_GLOBAL_SETTINGS = 0, + MMCO_UI_ANCHOR_INSTANCE_PAGE = 1, + MMCO_UI_ANCHOR_INSTANCE_SETTINGS = 2 +}; /* S19 — System Tray activation reason. * Mirrors QSystemTrayIcon::ActivationReason exactly (Qt 6): @@ -527,49 +562,6 @@ typedef struct MMCOContext { const char* prompt, const char* default_value); int (*ui_confirm_dialog)(void* mh, const char* title, const char* message); - /* DEPRECATED, no-op since the instance sidebar was fixed to a set list - * of instance-wide commands. Always returns 0 and registers nothing; - * the slot is kept only so existing modules still link. - * Use ui_register_instance_page() instead -- an instance window page is - * where per-instance plugin UI belongs. */ - int (*ui_register_instance_action)(void* mh, const char* text, - const char* tooltip, - const char* icon_name, - const char* page_id); - - /* DEPRECATED, no-op. See ui_register_instance_action above. */ - int (*ui_register_instance_action_cb)(void* mh, const char* text, - const char* tooltip, - const char* icon_name, - MMCOMenuActionCallback cb, void* ud); - - /* S13 — UI: Page Builder */ - void* (*ui_page_create)(void* mh, const char* page_id, - const char* display_name, const char* icon_name); - int (*ui_page_add_to_list)(void* mh, void* page, void* page_list_handle); - void* (*ui_layout_create)(void* mh, void* parent, int type); - int (*ui_layout_add_widget)(void* mh, void* layout, void* widget); - int (*ui_layout_add_layout)(void* mh, void* parent_layout, - void* child_layout); - int (*ui_layout_add_spacer)(void* mh, void* layout, int horizontal); - int (*ui_page_set_layout)(void* mh, void* page, void* layout); - void* (*ui_button_create)(void* mh, void* parent, const char* text, - const char* icon_name, MMCOButtonCallback cb, - void* ud); - int (*ui_button_set_enabled)(void* mh, void* button, int enabled); - int (*ui_button_set_text)(void* mh, void* button, const char* text); - void* (*ui_label_create)(void* mh, void* parent, const char* text); - int (*ui_label_set_text)(void* mh, void* label, const char* text); - void* (*ui_tree_create)(void* mh, void* parent, const char** column_names, - int column_count, MMCOTreeSelectionCallback cb, - void* ud); - int (*ui_tree_clear)(void* mh, void* tree); - int (*ui_tree_add_row)(void* mh, void* tree, const char** values, - int col_count); - int (*ui_tree_selected_row)(void* mh, void* tree); - int (*ui_tree_set_row_data)(void* mh, void* tree, int row, int64_t data); - int64_t (*ui_tree_get_row_data)(void* mh, void* tree, int row); - int (*ui_tree_row_count)(void* mh, void* tree); /* S14 — Utility */ const char* (*get_app_version)(void* mh); @@ -616,26 +608,20 @@ typedef struct MMCOContext { * tray_handle may be nullptr — a transient hidden tray is used. */ int (*tray_show_message)(void* mh, void* tray_handle, const char* title, const char* message, int icon_type, int msecs); - int (*tray_set_menu)(void* mh, void* tray_handle, void* menu_handle); + /* Attach a declarative menu to the tray icon — the menu pops up on + * right-click (ABI 5). `json_menu_doc` is a small "mmco-ui/1" tree + * whose root's children are `button` (menu item), `separator`, or + * `section` (submenu, itself containing more button/separator/ + * section children) nodes. Clicking an item fires `cb` with + * event="click" and node_id = the item's id. Pass json_menu_doc = + * nullptr to detach the menu. `cb`/`user_data` replace the previous + * per-action MMCOMenuActionCallback plumbing — one callback serves + * every item in the doc. Re-call with a freshly built document to + * rebuild the menu (e.g. on MMCO_HOOK_INSTANCE_CREATED/REMOVED). */ + int (*tray_set_menu)(void* mh, void* tray_handle, const char* json_menu_doc, + MMCOUiEventCallback cb, void* user_data); int (*tray_set_activation_cb)(void* mh, void* tray_handle, MMCOTrayActivationCallback cb, void* ud); - void* (*tray_menu_create)(void* mh); - int (*tray_menu_destroy)(void* mh, void* menu_handle); - int (*tray_menu_clear)(void* mh, void* menu_handle); - int (*tray_menu_add_separator)(void* mh, void* menu_handle); - void* (*tray_menu_add_action)(void* mh, void* menu_handle, - const char* label, const char* icon_name, - MMCOMenuActionCallback cb, void* ud); - int (*tray_menu_action_set_enabled)(void* mh, void* action_handle, - int enabled); - int (*tray_menu_action_set_text)(void* mh, void* action_handle, - const char* text); - /* Create a nested submenu under `parent_menu`. The returned handle - * is a QMenu* — pass it to the other tray_menu_* helpers. The - * submenu is parented to the parent menu and freed automatically - * when the parent menu is destroyed. */ - void* (*tray_menu_add_submenu)(void* mh, void* parent_menu, - const char* label, const char* icon_name); /* S20 — Main window helpers (additive) */ int (*main_window_install_close_filter)(void* mh, @@ -839,6 +825,61 @@ typedef struct MMCOContext { int (*progress_report)(void* handle, const char* status, const char* details, int64_t current, int64_t total); + + /* ─────────────────────────────────────────────────────────────── + * S33 — Declarative UI surfaces (ABI 5) + * + * Replaces the S13 imperative widget builder (ui_page_create / + * ui_layout_* / ui_button_* / ui_label_* / ui_tree_*, all gone as + * of ABI 5) and the allWidgets()/findChild() settings-injection + * pattern. A plugin describes a small widget tree as a JSON + * document (the "mmco-ui/1" format) and the host renders and owns + * the real QWidget tree; no QWidget* is ever handed back to a + * plugin. + * + * ui_surface_create — build and display a surface at the given + * anchor. `anchor_context` is nullptr for GLOBAL_SETTINGS, or + * the instance id for INSTANCE_PAGE / INSTANCE_SETTINGS. + * `title`/`icon_name` label the surface (page title for + * INSTANCE_PAGE, section title otherwise). `cb`/`user_data` + * receive every click/change/select event from nodes in the + * doc (see MMCOUiEventCallback). Returns an opaque surface + * handle, or nullptr on failure (bad JSON, unknown anchor). + * ui_surface_update — replace the whole document. + * ui_surface_set — patch one node's `props` (e.g. a toggle's + * value, a button's enabled state) without touching the rest + * of the tree. + * ui_surface_set_rows — replace a `list` node's `rows` only; + * the cheap refresh path, replacing the old + * ui_tree_clear + ui_tree_add_row loop. + * ui_surface_destroy — tear down a surface early. Every surface + * a module still owns is also torn down automatically when + * the module unloads. + * + * All four mutators return 0 on success, -1 on failure (unknown + * surface handle, malformed JSON, or unknown node_id). + * ─────────────────────────────────────────────────────────────── */ + void* (*ui_surface_create)(void* mh, int anchor, const char* anchor_context, + const char* title, const char* icon_name, + const char* json_doc, MMCOUiEventCallback cb, + void* user_data); + int (*ui_surface_update)(void* mh, void* surface, const char* json_doc); + int (*ui_surface_set)(void* mh, void* surface, const char* node_id, + const char* json_props); + int (*ui_surface_set_rows)(void* mh, void* surface, const char* node_id, + const char* json_rows); + int (*ui_surface_destroy)(void* mh, void* surface); + + /* Blocking: shows a small transient doc (must contain at least one + * `button`) parented to the active window, pumps a local event + * loop (same pattern as S26's account_skin_upload), and returns + * once a button fires. `out_result_json` receives + * {"button":"","fields":{"":"", ...}} — one entry + * per interactive node's current value at the time the button was + * clicked, truncated to fit `out_buf_size`. Returns 0 on a button + * click, -1 on bad arguments / malformed JSON. */ + int (*ui_modal_run)(void* mh, const char* title, const char* json_doc, + char* out_result_json, int out_buf_size); } MMCOContext; /* diff --git a/launcher/plugin/sdk/mmco_cxx_sdk.hpp b/launcher/plugin/sdk/mmco_cxx_sdk.hpp index b6bb1f1c..8c407604 100644 --- a/launcher/plugin/sdk/mmco_cxx_sdk.hpp +++ b/launcher/plugin/sdk/mmco_cxx_sdk.hpp @@ -64,7 +64,15 @@ #include "mmco_c_sdk.h" /* ── Qt facilities available to C++ plugins ─────────────────────────── */ -#include +/* ABI 5 dropped the Qt::Widgets link from MeshMC::SDK (see + * plugin-abi5-spec.md §4 step 3 and sdk/CMakeLists.txt): every ui_ and + * tray_menu_ call a plugin used to build QWidget/QMenu/QAction trees + * with by hand is gone, replaced by ui_surface_create's "mmco-ui/1" + * JSON documents (rendered host-side, MeshMC_logic-only, by + * PluginUiRenderer) and tray_set_menu's JSON menu doc. Only QtCore and + * QtGui headers are pulled in below; a plugin that still needs a + * genuine QWidget (e.g. to parent a native dialog some other way) must + * link Qt::Widgets itself and include the specific header it needs. */ #include #include #include @@ -77,26 +85,9 @@ #include #include #include -#include -#include -#include #include -#include -#include -#include -#include #include #include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include #include #include diff --git a/launcher/qml/AccountFaceProvider.cpp b/launcher/qml/AccountFaceProvider.cpp new file mode 100644 index 00000000..ea7d7a58 --- /dev/null +++ b/launcher/qml/AccountFaceProvider.cpp @@ -0,0 +1,256 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "AccountFaceProvider.h" + +#include + +#include "core/LauncherContext.h" +#include "minecraft/auth/AccountList.h" +#include "minecraft/auth/MinecraftAccount.h" + +namespace +{ +// Only a fallback for an unrecognised or zero requestedSize; mirrors +// InstanceIconProvider's own default extent. +constexpr int kDefaultFaceExtent = 64; + +// Standard Minecraft skin texture layout: the face is the front of the +// head, the hat is its second-layer overlay -- same coordinates +// MinecraftAccount::getFace() and SkinUtils::getFaceFromCache() draw. +constexpr int kFaceX = 8; +constexpr int kFaceY = 8; +constexpr int kHatX = 40; +constexpr int kHatY = 8; +constexpr int kFaceExtent = 8; + +// Prefix an id is checked for (after the "?rev=N" cache-buster is already +// stripped) to ask for the full body instead of the face. +const QString kBodyPrefix = QStringLiteral("body/"); + +// Front-view body layout, all still the standard Minecraft skin UV: torso +// and legs are always this wide, only the arms narrow for the slim variant. +constexpr int kLimbHeight = 12; +constexpr int kTorsoWidth = 8; +constexpr int kLegWidth = 4; +constexpr int kClassicArmWidth = 4; +constexpr int kSlimArmWidth = 3; +constexpr int kHeadSize = 8; +// A body render's default box, at the classic (non-slim) aspect ratio; +// only hit for a caller that asks for a body without a requestedSize. +constexpr int kDefaultBodyWidth = kClassicArmWidth * 2 + kTorsoWidth; +constexpr int kDefaultBodyHeight = kHeadSize + kLimbHeight * 2; + +// Every real Minecraft skin texture is this wide, legacy or modern alike. +constexpr int kSkinWidth = 64; +// Skin texture height that carries the modern jacket/sleeves/pants overlay +// layer and a distinct left arm/leg. Anything shorter (down to +// kLegacySkinHeight) is the legacy format, which has neither -- the left +// side is mirrored from the right instead. +constexpr int kModernSkinHeight = 64; +constexpr int kLegacySkinHeight = 32; + +QPixmap transparentPixmap(const QSize& size) +{ + QImage image(size, QImage::Format_ARGB32_Premultiplied); + image.fill(Qt::transparent); + return QPixmap::fromImage(image); +} + +// AccountList exposes a profile-id finder but not an internal-id one, and +// offline accounts have no profile id at all -- so a key that misses as a +// profile id is tried again as an internalId(), by linear scan over the +// same public count()/at() API AccountList::findAccountByProfileId() itself +// uses. +MinecraftAccountPtr findAccount(AccountList* accounts, const QString& key) +{ + if (!accounts || key.isEmpty()) { + return MinecraftAccountPtr(); + } + + const int byProfileId = accounts->findAccountByProfileId(key); + if (byProfileId != -1) { + return accounts->at(byProfileId); + } + + for (int i = 0; i < accounts->count(); ++i) { + MinecraftAccountPtr account = accounts->at(i); + if (account && account->internalId() == key) { + return account; + } + } + return MinecraftAccountPtr(); +} +} // namespace + +AccountFaceProvider::AccountFaceProvider() + : QQuickImageProvider(QQuickImageProvider::Pixmap) +{ +} + +QImage AccountFaceProvider::faceFromSkin(const QImage& skin) +{ + if (skin.isNull() || skin.width() < kHatX + kFaceExtent || + skin.height() < kFaceY + kFaceExtent) { + return QImage(); + } + + QImage face(kFaceExtent, kFaceExtent, QImage::Format_ARGB32_Premultiplied); + face.fill(Qt::transparent); + + QPainter painter(&face); + painter.drawImage( + 0, 0, skin.copy(kFaceX, kFaceY, kFaceExtent, kFaceExtent)); + painter.drawImage(0, 0, skin.copy(kHatX, kHatY, kFaceExtent, kFaceExtent)); + painter.end(); + return face; +} + +QImage AccountFaceProvider::bodyFromSkin(const QImage& skin, bool slim) +{ + if (skin.isNull() || skin.width() < kSkinWidth || + skin.height() < kLegacySkinHeight) { + return QImage(); + } + + // The legacy 64x32 format has no overlay layer (jacket/sleeves/pants) + // and no distinct left arm/left leg region -- both are synthesised + // below by mirroring the right side. + const bool legacy = skin.height() < kModernSkinHeight; + + const int armWidth = slim ? kSlimArmWidth : kClassicArmWidth; + const int bodyWidth = armWidth * 2 + kTorsoWidth; + const int bodyHeight = kHeadSize + kLimbHeight * 2; + const int limbY = kHeadSize; + const int legY = limbY + kLimbHeight; + // The torso sits between the two arms; both leg columns sit directly + // under it, together spanning the same width. + const int torsoX = armWidth; + const int rightLegX = armWidth; + const int leftLegX = rightLegX + kLegWidth; + const int leftArmX = armWidth + kTorsoWidth; + + QImage body(bodyWidth, bodyHeight, QImage::Format_ARGB32_Premultiplied); + body.fill(Qt::transparent); + + QPainter painter(&body); + + // Head, centred above the torso, hat overlay included -- present in + // both skin formats. + painter.drawImage(torsoX, 0, skin.copy(kFaceX, kFaceY, kHeadSize, kHeadSize)); + painter.drawImage(torsoX, 0, skin.copy(kHatX, kHatY, kHeadSize, kHeadSize)); + + // Torso, with its jacket overlay (modern format only). + painter.drawImage(torsoX, limbY, skin.copy(20, 20, kTorsoWidth, kLimbHeight)); + if (!legacy) { + painter.drawImage( + torsoX, limbY, skin.copy(20, 36, kTorsoWidth, kLimbHeight)); + } + + // The character faces the viewer, so -- as in a mirror -- its right + // arm/leg render on the left of the image and its left arm/leg on the + // right. Only armWidth columns of each region are sampled: for the slim + // variant that is 3 of the texture's 4, leaving the same blank 4th + // column the game itself never draws. + const QImage rightArm = skin.copy(44, 20, armWidth, kLimbHeight); + painter.drawImage(0, limbY, rightArm); + if (!legacy) { + painter.drawImage(0, limbY, skin.copy(44, 36, armWidth, kLimbHeight)); + } + if (legacy) { + // No separate left arm texture to draw -- the right one already + // includes its (only) layer, so just mirror the composited result. + painter.drawImage(leftArmX, limbY, rightArm.mirrored(true, false)); + } else { + painter.drawImage( + leftArmX, limbY, skin.copy(36, 52, armWidth, kLimbHeight)); + painter.drawImage( + leftArmX, limbY, skin.copy(52, 52, armWidth, kLimbHeight)); + } + + const QImage rightLeg = skin.copy(4, 20, kLegWidth, kLimbHeight); + painter.drawImage(rightLegX, legY, rightLeg); + if (!legacy) { + painter.drawImage( + rightLegX, legY, skin.copy(4, 36, kLegWidth, kLimbHeight)); + } + if (legacy) { + painter.drawImage(leftLegX, legY, rightLeg.mirrored(true, false)); + } else { + painter.drawImage( + leftLegX, legY, skin.copy(20, 52, kLegWidth, kLimbHeight)); + painter.drawImage( + leftLegX, legY, skin.copy(4, 52, kLegWidth, kLimbHeight)); + } + + painter.end(); + return body; +} + +QPixmap AccountFaceProvider::requestPixmap(const QString& id, QSize* size, + const QSize& requestedSize) +{ + // Cache-busting query ("?rev=N") plays no part in the lookup -- see + // the header for why it exists at all. + const int queryStart = id.indexOf(QLatin1Char('?')); + const QString withoutQuery = queryStart < 0 ? id : id.left(queryStart); + + const bool wantsBody = withoutQuery.startsWith(kBodyPrefix); + const QString key = + wantsBody ? withoutQuery.mid(kBodyPrefix.length()) : withoutQuery; + + // requestedSize is already in device pixels -- QML applied the device + // pixel ratio before calling here -- so it is used as-is. + const QSize defaultSize = wantsBody + ? QSize(kDefaultBodyWidth, kDefaultBodyHeight) + : QSize(kDefaultFaceExtent, kDefaultFaceExtent); + const QSize wanted = requestedSize.isEmpty() ? defaultSize : requestedSize; + + QPixmap pixmap; + if (auto* context = LauncherContext::instance()) { + MinecraftAccountPtr account = + findAccount(context->accounts().get(), key); + if (account) { + QImage skin; + if (skin.loadFromData( + account->accountData()->minecraftProfile.skin.data, + "PNG")) { + const bool slim = account->accountData() + ->minecraftProfile.skin.variant.compare( + QLatin1String("SLIM"), + Qt::CaseInsensitive) == 0; + const QImage composited = + wantsBody ? bodyFromSkin(skin, slim) : faceFromSkin(skin); + if (!composited.isNull()) { + pixmap = QPixmap::fromImage(composited).scaled( + wanted, Qt::KeepAspectRatio, Qt::FastTransformation); + } + } + } + } + + if (pixmap.isNull()) { + // No account, no skin, or the context is not up yet -- QML's own + // fallback avatar is what should show, not a broken-image icon. + pixmap = transparentPixmap(wanted); + } + + *size = pixmap.size(); + return pixmap; +} diff --git a/launcher/qml/AccountFaceProvider.h b/launcher/qml/AccountFaceProvider.h new file mode 100644 index 00000000..4dfeba76 --- /dev/null +++ b/launcher/qml/AccountFaceProvider.h @@ -0,0 +1,117 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include +#include + +/* + * Bridges an account's Minecraft skin -- the face, or (since MeshMC wants the + * signature "standing on a stage" look for the Accounts hero) the full front- + * facing body -- into QML, the same way InstanceIconProvider (see + * InstanceIconProvider.h) bridges IconList. A delegate writes + * + * Image { source: "image://accountface/" + accountId } // face + * Image { source: "image://accountface/body/" + accountId } // body + * + * where accountId is a profile id or an account's internalId() -- whichever + * QmlShell ends up exposing as the default account's identifier. The + * "body/" prefix is stripped before lookup, exactly like the "?rev=N" cache- + * buster below, so both forms find the same account. + * + * THREADING. Pixmap type, not Image or an async provider, for exactly the + * reason InstanceIconProvider.h gives: QPixmap/QImage compositing is + * GUI-thread-only, and Pixmap is the only one of the three + * QQuickImageProvider types Qt Quick guarantees calls requestPixmap() on the + * GUI thread. + * + * SIZE. requestedSize arrives already in device pixels -- QML has folded in + * the device pixel ratio before calling here. An invalid or empty + * requestedSize falls back to a fixed extent (per kind -- see + * requestPixmap()). Both the face and the body are scaled with + * Qt::FastTransformation (nearest-neighbour), not the smooth default: this + * is pixel art, and smooth-scaling it up would blur the very pixel edges + * that make a skin recognisable. + * + * CACHE-BUSTING. Same "?rev=N" convention as InstanceIconProvider: the + * query string is stripped before lookup and changes nothing about which + * account is found. It exists purely so a caller can force QML to refetch a + * URL it has already resolved, once the account's skin might have changed + * (AccountList::listChanged/defaultAccountChanged move which account is + * default; the account's own changed() fires when its data, including the + * skin, is refreshed). Bumping the revision when either fires is left to + * whoever wires this provider into the engine. + * + * LOOKUP. Looks the account up in LAUNCHER->accounts() (core/LauncherContext.h) + * by profile id first, falling back to a linear scan by internalId(): + * AccountList exposes findAccountByProfileId() but no internal-id finder, + * and offline accounts have no profile id at all. No matching account, no + * skin data, or a LauncherContext that is not up yet (see + * LauncherContext::instance()) all return a transparent pixmap rather than a + * null one -- QML's own fallback avatar is what should show, not a + * broken-image icon. That fallback matters most for the body: offline + * accounts (and any account before its profile texture is fetched) have no + * skin bytes at all, and the Accounts hero draws its own neutral silhouette + * underneath the (then fully transparent) body image for exactly that case. + */ +class AccountFaceProvider : public QQuickImageProvider +{ + public: + explicit AccountFaceProvider(); + + QPixmap requestPixmap(const QString& id, QSize* size, + const QSize& requestedSize) override; + + /* Composites the face (the 8x8 region at (8,8)) with the hat overlay + * (the 8x8 region at (40,8)) of a full skin texture, at native 8x8 + * resolution -- the same two regions MinecraftAccount::getFace() and + * SkinUtils::getFaceFromCache() already draw. Kept as a pure function + * of the skin image, separate from any account lookup, so it can be + * unit tested without a QGuiApplication, a real account or a network -- + * see AccountFaceProvider_test.cpp. Returns a null QImage if skin is too + * small to contain both regions. + * + * Hat pixels with alpha 0 leave the face beneath them untouched: the + * face layer is drawn first, filling the whole canvas, and the hat is + * composited over it with ordinary SourceOver painting, which is a + * no-op wherever the source alpha is 0. */ + static QImage faceFromSkin(const QImage& skin); + + /* Composites a front-facing full body from a skin texture: head (with + * hat), torso (with jacket), both arms (with sleeves) and both legs + * (with pants), laid out the way the game itself poses a standing + * player -- arms at the sides, so the character's right arm/leg render + * on the left of the image and the left arm/leg on the right (as they do + * when facing the viewer). @p slim narrows both arms from 4px to 3px, + * matching the account's skin variant ("SLIM" == Alex-style thin arms). + * + * Accepts both skin formats: the modern 64x64 texture (with the jacket/ + * sleeve/pants overlay layer and a distinct left arm/leg) and the legacy + * 64x32 one, which has neither -- the left arm and leg are mirrored from + * the right for it instead. Returns a null QImage if skin is too small + * for even the legacy layout. + * + * A pure function of the skin image, same rationale as faceFromSkin(): + * unit-testable without any account, engine or network. */ + static QImage bodyFromSkin(const QImage& skin, bool slim); +}; diff --git a/launcher/qml/AccountFaceProvider_test.cpp b/launcher/qml/AccountFaceProvider_test.cpp new file mode 100644 index 00000000..5d9ed51a --- /dev/null +++ b/launcher/qml/AccountFaceProvider_test.cpp @@ -0,0 +1,292 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include +#include + +#include "AccountFaceProvider.h" + +namespace +{ +// Same layout AccountFaceProvider.cpp composites from -- restated here +// rather than included, so the test proves the coordinates against a fresh +// reading of "what a real skin texture looks like", not against whatever the +// implementation happens to believe them to be. +constexpr int kSkinExtent = 64; +constexpr int kFaceX = 8; +constexpr int kFaceY = 8; +constexpr int kHatX = 40; +constexpr int kHatY = 8; +constexpr int kFaceExtent = 8; + +// A synthetic skin texture: the face region filled with faceColor, and +// (optionally) the hat region filled with hatColor. When hatOpaque is +// false the hat region is left fully transparent, which is what +// QImage::fill(Qt::transparent) already puts there. +QImage makeSkin(const QColor& faceColor, const QColor& hatColor, + bool hatOpaque) +{ + QImage skin(kSkinExtent, kSkinExtent, QImage::Format_ARGB32_Premultiplied); + skin.fill(Qt::transparent); + + for (int y = 0; y < kFaceExtent; ++y) { + for (int x = 0; x < kFaceExtent; ++x) { + skin.setPixelColor(kFaceX + x, kFaceY + y, faceColor); + } + } + + if (hatOpaque) { + for (int y = 0; y < kFaceExtent; ++y) { + for (int x = 0; x < kFaceExtent; ++x) { + skin.setPixelColor(kHatX + x, kHatY + y, hatColor); + } + } + } + + return skin; +} + +// Same front-view body layout AccountFaceProvider.cpp composites from, +// restated here for the same reason kSkinExtent etc. above are. +constexpr int kLimbHeight = 12; +constexpr int kTorsoWidth = 8; +constexpr int kLegWidth = 4; +constexpr int kClassicArmWidth = 4; +constexpr int kSlimArmWidth = 3; + +// A synthetic full skin texture: head, torso, right arm and right leg each +// filled with their own flat colour, so a region landing in the wrong place +// in the composited body is obvious. @p height is 64 for the modern format +// (which also gets a distinctly-coloured left arm/leg) or 32 for the legacy +// one (which has neither, and relies on bodyFromSkin() mirroring the right +// side instead). +QImage makeBodySkin(int height) +{ + QImage skin(kSkinExtent, height, QImage::Format_ARGB32_Premultiplied); + skin.fill(Qt::transparent); + + auto fill = [&](int x, int y, int w, int h, const QColor& color) { + for (int yy = 0; yy < h; ++yy) { + for (int xx = 0; xx < w; ++xx) { + skin.setPixelColor(x + xx, y + yy, color); + } + } + }; + + fill(kFaceX, kFaceY, kFaceExtent, kFaceExtent, Qt::red); // head + fill(20, 20, kTorsoWidth, kLimbHeight, Qt::blue); // torso + fill(44, 20, kClassicArmWidth, kLimbHeight, Qt::green); // right arm + fill(4, 20, kLegWidth, kLimbHeight, Qt::yellow); // right leg + + if (height >= 64) { + fill(36, 52, kClassicArmWidth, kLimbHeight, Qt::cyan); // left arm + fill(20, 52, kLegWidth, kLimbHeight, Qt::magenta); // left leg + } + + return skin; +} +} // namespace + +/* + * Unit test for AccountFaceProvider::faceFromSkin(), the pure + * face-compositing helper. Deliberately does not exercise + * AccountFaceProvider::requestPixmap() itself: that needs a real + * LauncherContext/AccountList/MinecraftAccount (network-backed, or at least + * a real Application), which is neither cheap nor hermetic here. The helper + * is where the interesting logic (and the interesting bug -- a hat pixel + * clobbering the face where it should be transparent) actually lives. + */ +class AccountFaceProviderTest : public QObject +{ + Q_OBJECT + + private slots: + void faceIsEightByEight() + { + const QImage skin = + makeSkin(Qt::red, Qt::blue, /*hatOpaque=*/false); + + const QImage face = AccountFaceProvider::faceFromSkin(skin); + + QVERIFY(!face.isNull()); + QCOMPARE(face.size(), QSize(kFaceExtent, kFaceExtent)); + } + + void transparentHatDoesNotCoverFace() + { + const QColor faceColor(255, 0, 0, 255); + const QImage skin = makeSkin(faceColor, Qt::blue, /*hatOpaque=*/false); + + const QImage face = AccountFaceProvider::faceFromSkin(skin); + + for (int y = 0; y < kFaceExtent; ++y) { + for (int x = 0; x < kFaceExtent; ++x) { + QCOMPARE(face.pixelColor(x, y), faceColor); + } + } + } + + void opaqueHatCoversFace() + { + const QColor faceColor(255, 0, 0, 255); + const QColor hatColor(0, 0, 255, 255); + const QImage skin = makeSkin(faceColor, hatColor, /*hatOpaque=*/true); + + const QImage face = AccountFaceProvider::faceFromSkin(skin); + + for (int y = 0; y < kFaceExtent; ++y) { + for (int x = 0; x < kFaceExtent; ++x) { + QCOMPARE(face.pixelColor(x, y), hatColor); + } + } + } + + // Proves per-pixel alpha compositing, not an all-or-nothing overlay + // draw: only the one opaque hat pixel should show through, everything + // else stays the face colour. + void partiallyTransparentHatBlendsPerPixel() + { + const QColor faceColor(255, 0, 0, 255); + const QColor hatPixelColor(0, 255, 0, 255); + QImage skin = makeSkin(faceColor, Qt::blue, /*hatOpaque=*/false); + skin.setPixelColor(kHatX + 3, kHatY + 3, hatPixelColor); + + const QImage face = AccountFaceProvider::faceFromSkin(skin); + + QCOMPARE(face.pixelColor(3, 3), hatPixelColor); + QCOMPARE(face.pixelColor(0, 0), faceColor); + QCOMPARE(face.pixelColor(7, 7), faceColor); + } + + void tooSmallSkinReturnsNullImage() + { + QImage tiny(16, 16, QImage::Format_ARGB32_Premultiplied); + tiny.fill(Qt::transparent); + + QVERIFY(AccountFaceProvider::faceFromSkin(tiny).isNull()); + } + + void nullSkinReturnsNullImage() + { + QVERIFY(AccountFaceProvider::faceFromSkin(QImage()).isNull()); + } + + // --- AccountFaceProvider::bodyFromSkin() --- + + void bodyIsClassicAspectByDefault() + { + const QImage skin = makeBodySkin(64); + + const QImage body = AccountFaceProvider::bodyFromSkin(skin, /*slim=*/false); + + QVERIFY(!body.isNull()); + QCOMPARE(body.size(), + QSize(kClassicArmWidth * 2 + kTorsoWidth, + kFaceExtent + kLimbHeight * 2)); + } + + void slimBodyIsNarrower() + { + const QImage skin = makeBodySkin(64); + + const QImage body = AccountFaceProvider::bodyFromSkin(skin, /*slim=*/true); + + QVERIFY(!body.isNull()); + QCOMPARE(body.width(), kSlimArmWidth * 2 + kTorsoWidth); + QCOMPARE(body.height(), kFaceExtent + kLimbHeight * 2); + } + + // The character faces the viewer, so its right arm/leg -- the ones a + // skin texture always carries, legacy or modern -- render on the left + // of the composited image, and the torso sits right after them. + void rightArmAndLegLandLeftOfTorso() + { + const QImage skin = makeBodySkin(64); + + const QImage body = AccountFaceProvider::bodyFromSkin(skin, /*slim=*/false); + + QCOMPARE(body.pixelColor(0, kFaceExtent), QColor(Qt::green)); // arm + QCOMPARE(body.pixelColor(kClassicArmWidth, kFaceExtent), + QColor(Qt::blue)); // torso starts right after it + QCOMPARE(body.pixelColor(kClassicArmWidth, kFaceExtent + kLimbHeight), + QColor(Qt::yellow)); // leg, under the torso's left half + } + + void modernSkinUsesItsOwnLeftArmAndLeg() + { + const QImage skin = makeBodySkin(64); + + const QImage body = AccountFaceProvider::bodyFromSkin(skin, /*slim=*/false); + + const int leftArmX = kClassicArmWidth + kTorsoWidth; + const int leftLegX = kClassicArmWidth + kLegWidth; + QCOMPARE(body.pixelColor(leftArmX, kFaceExtent), QColor(Qt::cyan)); + QCOMPARE(body.pixelColor(leftLegX, kFaceExtent + kLimbHeight), + QColor(Qt::magenta)); + } + + // The legacy 64x32 format has no separate left arm/leg texture at all, + // so bodyFromSkin() must synthesise the left side by mirroring the + // right -- checked here with an asymmetric arm so a mirror and a plain + // copy cannot be confused for each other. + void legacySkinMirrorsRightArmAndLegForLeft() + { + QImage skin = makeBodySkin(32); + skin.setPixelColor(44, 20, Qt::red); // leftmost column + skin.setPixelColor(44 + kClassicArmWidth - 1, 20, Qt::blue); // rightmost + + const QImage body = AccountFaceProvider::bodyFromSkin(skin, /*slim=*/false); + + QVERIFY(!body.isNull()); + const int leftArmX = kClassicArmWidth + kTorsoWidth; + // Mirrored: the arm's rightmost column becomes the leftmost here, + // and vice versa. + QCOMPARE(body.pixelColor(leftArmX, kFaceExtent), QColor(Qt::blue)); + QCOMPARE(body.pixelColor(leftArmX + kClassicArmWidth - 1, kFaceExtent), + QColor(Qt::red)); + } + + void bodyHeadIncludesHatOverlay() + { + QImage skin = makeBodySkin(64); + skin.setPixelColor(kHatX, kHatY, QColor(0, 0, 0, 255)); + + const QImage body = AccountFaceProvider::bodyFromSkin(skin, /*slim=*/false); + + QCOMPARE(body.pixelColor(kClassicArmWidth, 0), QColor(0, 0, 0, 255)); + } + + void tooSmallSkinReturnsNullBody() + { + QImage tiny(16, 16, QImage::Format_ARGB32_Premultiplied); + tiny.fill(Qt::transparent); + + QVERIFY(AccountFaceProvider::bodyFromSkin(tiny, false).isNull()); + } + + void nullSkinReturnsNullBody() + { + QVERIFY(AccountFaceProvider::bodyFromSkin(QImage(), false).isNull()); + } +}; + +QTEST_GUILESS_MAIN(AccountFaceProviderTest) + +#include "AccountFaceProvider_test.moc" diff --git a/launcher/qml/CMakeLists.txt b/launcher/qml/CMakeLists.txt index 4fc84bd3..1701e97f 100644 --- a/launcher/qml/CMakeLists.txt +++ b/launcher/qml/CMakeLists.txt @@ -18,6 +18,21 @@ # which is Qt 6.5+; the floor here is 6.4 (Debian 12, Ubuntu 24.04 LTS). Setting # it by hand gives the same qrc:/qt/qml// layout on every supported Qt, so # these URLs do not move when the floor is eventually raised. +# The design system, one module per concern, each in its own directory for the +# same resource-alias reason as this one. Declared first because the modules +# below import them. +add_subdirectory(Theme) +add_subdirectory(Style) +add_subdirectory(Components) + +# The roaming 3D cat, only when Qt Quick3D was found (see the top-level +# CMakeLists.txt's find_package(... OPTIONAL_COMPONENTS Quick3D) and the +# MeshMC_ENABLE_CAT option next to it). Kept out of the unconditional list +# above so a Quick3D-less build never even sees Cat/'s CMakeLists.txt. +if(MeshMC_ENABLE_CAT) + add_subdirectory(Cat) +endif() + qt_add_library(MeshMC_qml STATIC) qt_add_qml_module(MeshMC_qml @@ -28,7 +43,50 @@ qt_add_qml_module(MeshMC_qml Main.qml ) +# The C++ that drives the QML: the engine owner and whatever it hands over. +target_sources(MeshMC_qml PRIVATE + QmlShell.h + QmlShell.cpp + QmlUiHost.h + QmlUiHost.cpp + InstanceIconProvider.h + InstanceIconProvider.cpp + AccountFaceProvider.h + AccountFaceProvider.cpp + ScreenshotThumbnailProvider.h + ScreenshotThumbnailProvider.cpp +) + +# The QML side reads the core's models. It does not see QtWidgets: MeshMC_core +# cannot link them, and nothing here asks for them. target_link_libraries(MeshMC_qml PUBLIC + MeshMC_qml_theme + MeshMC_qml_style + MeshMC_qml_components + MeshMC_core +) + +# The plugin targets, and not just the modules' backing libraries, have to reach +# the executable: qt_import_qml_plugins only imports plugins it finds in the +# link closure, and a module whose plugin is missing fails at load time with +# 'plugin "..." not found' -- after a perfectly green build. PUBLIC so they +# propagate from here to whatever links MeshMC_qml. +target_link_libraries(MeshMC_qml PUBLIC + MeshMC_qml_themeplugin + MeshMC_qml_styleplugin + MeshMC_qml_componentsplugin Qt${QT_VERSION_MAJOR}::Quick Qt${QT_VERSION_MAJOR}::QuickControls2 ) + +# The cat's module and plugin, linked the same way as the three above, plus +# the compile-time flag QmlShell.cpp reads to tell QML whether Main.qml's +# catLoader has anything to load at all -- see QmlShell::rootProperties(). +# Both stay entirely out of a build with MeshMC_ENABLE_CAT off. +if(MeshMC_ENABLE_CAT) + target_link_libraries(MeshMC_qml PUBLIC + MeshMC_qml_cat + MeshMC_qml_catplugin + ) + target_compile_definitions(MeshMC_qml PRIVATE MESHMC_HAS_CAT=1) +endif() diff --git a/launcher/qml/Cat/CMakeLists.txt b/launcher/qml/Cat/CMakeLists.txt new file mode 100644 index 00000000..e4b340b2 --- /dev/null +++ b/launcher/qml/Cat/CMakeLists.txt @@ -0,0 +1,75 @@ +# SPDX-FileCopyrightText: 2026 Project Tick +# SPDX-FileContributor: Project Tick +# SPDX-License-Identifier: Apache-2.0 + +######## MeshMC.Cat: the optional 3D cat companion ######## + +# Only ever added from launcher/qml/CMakeLists.txt's +# `if(MeshMC_ENABLE_CAT) add_subdirectory(Cat)`, itself gated on the +# top-level CMakeLists.txt's find_package(... OPTIONAL_COMPONENTS Quick3D) +# -- so by the time this file runs, Qt::Quick3D is known to exist. Same +# per-module-CMakeLists shape as Theme/Style/Components: RESOURCE_PREFIX +# spelled out by hand (the 6.4 floor predates qt_policy(QTP0001)), and this +# file lives beside the .qml it lists so the URI (MeshMC.Cat) and the +# on-disk path agree. +qt_add_library(MeshMC_qml_cat STATIC) + +qt_add_qml_module(MeshMC_qml_cat + URI MeshMC.Cat + VERSION 1.0 + RESOURCE_PREFIX "/qt/qml" + # CatOverlay.qml `import MeshMC.Components` for SettingsStore (the + # launcher-wide settings singleton -- CatEnabled/CatVariant/ + # UiReduceMotion are ordinary settings, read the same way any other QML + # file reads them). Declared here, the same way Style/CMakeLists.txt + # declares MeshMC.Theme, so this module's own build step can resolve + # that import and the generated qmldir records the dependency. + IMPORTS + MeshMC.Components + # Not IMPORTS: CatOverlay.qml/CatRig.qml `import QtQuick3D`, + # `QtQuick.Window` and `QtQuick.Controls` themselves, the same way they + # import MeshMC.Components above rather than relying on an implicit one. + # DEPENDENCIES instead, the same way Style/CMakeLists.txt lists + # QtQuick.Templates, so this module's own qmldir records the runtime + # dependency for tooling that reads it (qmlimportscanner already finds + # all three from the `import` statements regardless -- see the + # packaging comments in launcher/CMakeLists.txt). + DEPENDENCIES + QtQuick3D + QtQuick.Window + QtQuick.Controls + QML_FILES + CatOverlay.qml + CatRig.qml + # The model's parts (generated from assets/cat.glb with Qt's balsam + # tool -- see assets/CREDITS.md) and its coats. assets/cat.glb itself is + # the source and is not needed at run time. + RESOURCES + model/meshes/object_0_mesh.mesh + model/meshes/object_1_mesh.mesh + model/meshes/object_2_mesh.mesh + model/meshes/object_3_mesh.mesh + model/meshes/object_4_mesh.mesh + model/meshes/object_5_mesh.mesh + model/meshes/object_6_mesh.mesh + model/meshes/object_7_mesh.mesh + model/meshes/object_8_mesh.mesh + model/meshes/object_9_mesh.mesh + model/meshes/object_10_mesh.mesh + textures/cat_calico.png + textures/cat_ginger.png + textures/cat_black.png + textures/cat_white.png + textures/cat_siamese.png + textures/heart.png + textures/sleep_z.png +) + +target_link_libraries(MeshMC_qml_cat PUBLIC + Qt${QT_VERSION_MAJOR}::Quick + Qt${QT_VERSION_MAJOR}::Quick3D + # For the `Overlay` attached type CatOverlay.qml reads (import + # QtQuick.Controls) -- same reasoning as Style/CMakeLists.txt linking + # this for the controls it imports directly. + Qt${QT_VERSION_MAJOR}::QuickControls2 +) diff --git a/launcher/qml/Cat/CatOverlay.qml b/launcher/qml/Cat/CatOverlay.qml new file mode 100644 index 00000000..688c86a5 --- /dev/null +++ b/launcher/qml/Cat/CatOverlay.qml @@ -0,0 +1,446 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Window +import QtQuick.Controls +import QtQuick3D +import MeshMC.Components + +/* + * The launcher's cat (a nod to MultiMC's): a small 3D Minecraft cat that + * lives on the bottom edge of the page area -- the top of the play bar is + * its floor -- and wanders along it, sits, loafs, sleeps, stretches and + * looks at the pointer. Click it to pet it; double-click and it hops. + * + * Loaded by Main.qml's catLoader over the page area. Only the cat itself + * takes clicks (catMouse); the pointer tracking is a passive HoverHandler, + * so nothing under the overlay stops working. + * + * Cost: the View3D is tiny, and it only redraws while something moves. + * Walking is the one continuous animation; idling sways the tail a few + * times and stops, sleeping breathes in coarse steps a few times a second, + * and everything stops -- the cat fades out -- while the window is inactive, + * a dialog is open or "Reduce motion" is on. + */ +Item { + id: root + anchors.fill: parent + + // "" runs the cat normally; "walk", "idle", "loaf", "sleep", "stretch" + // or "pet" freezes it in that pose at a fixed spot, for review + // snapshots (MESHMC_QML_ROUTE's "cat=" step, see Main.qml). + property string demo: "" + + readonly property var variants: ["calico", "ginger", "black", "white", "siamese"] + readonly property string variant: { + const v = SettingsStore.string("CatVariant") + return root.variants.indexOf(v) >= 0 ? v : "calico" + } + + // -- Pausing ------------------------------------------------------------ + readonly property bool reduceMotion: SettingsStore.bool("UiReduceMotion") + readonly property bool windowActive: root.Window.window ? root.Window.active : true + // Every open Popup/Dialog is reparented into the window's overlay, so + // counting its children says whether one is open without Main.qml + // naming them all. + readonly property int openPopups: Overlay.overlay ? Overlay.overlay.children.length : 0 + readonly property bool paused: root.demo === "" + && (!root.windowActive || root.reduceMotion || root.openPopups > 0) + + opacity: root.paused ? 0 : 1 + visible: opacity > 0.01 + Behavior on opacity { NumberAnimation { duration: 240; easing.type: Easing.OutCubic } } + + // -- Where the cat is --------------------------------------------------- + // The stage's horizontal centre, and the range it may walk in (clear of + // the window edges). + readonly property real minX: 70 + readonly property real maxX: Math.max(root.minX + 1, root.width - 90) + property real catX: root.width * 0.72 + // Facing: -1 left, 1 right. The model turns a little towards the viewer + // (115 degrees rather than 90) so its face shows while it walks. + property int facing: -1 + property real facingYaw: root.facing > 0 ? -115 : 115 + Behavior on facingYaw { NumberAnimation { duration: 280; easing.type: Easing.OutQuad } } + + // -- What the cat is doing ---------------------------------------------- + // walk, idle, loaf, sleep, stretch; "pet" and "hop" are short + // interruptions that return to idle. + property string action: "idle" + property int idleRounds: 0 + // Bumped on every pet, to start the hearts (see the hearts Repeater). + property int petPulse: 0 + + function randomBetween(lo, hi) { return lo + Math.random() * (hi - lo) } + + function setAction(next) { + root.action = next + rig.walkAmount = next === "walk" ? 1 : 0 + rig.loaf = next === "loaf" || next === "sleep" ? 1 : 0 + rig.sleep = next === "sleep" ? 1 : 0 + rig.stretch = next === "stretch" ? 1 : 0 + if (next !== "sleep") + rig.breath = 0 + if (next === "idle" || next === "loaf") + tailSway.restart() + brain.interval = next === "sleep" ? root.randomBetween(18000, 32000) + : next === "loaf" ? root.randomBetween(6000, 11000) + : next === "stretch" ? 1600 + : root.randomBetween(2500, 6000) + brain.restart() + } + + function walkTo(x) { + const target = Math.max(root.minX, Math.min(root.maxX, x)) + const distance = target - root.catX + if (Math.abs(distance) < 12) { + root.setAction("idle") + return + } + root.facing = distance > 0 ? 1 : -1 + stroll.to = target + stroll.duration = Math.abs(distance) / 55 * 1000 + root.setAction("walk") + brain.stop() + stroll.restart() + } + + // Picks the next thing to do when the current one runs out. + function decide() { + if (root.demo !== "") + return + if (root.action === "sleep") { + root.setAction("stretch") + return + } + if (root.action === "walk" || root.action === "stretch" || root.action === "loaf") { + root.idleRounds = 0 + root.setAction("idle") + return + } + root.idleRounds += 1 + const roll = Math.random() + if (root.idleRounds >= 4 && roll < 0.5) + root.setAction(roll < 0.3 ? "sleep" : "loaf") + else if (roll < 0.55) + root.walkTo(root.catX + root.randomBetween(-320, 320)) + else if (roll < 0.7) + root.setAction("loaf") + else + root.setAction("idle") + } + + // Started and stopped by hand (setAction, walkTo, pet); the pause below + // only suspends it. + Timer { + id: brain + onTriggered: root.decide() + } + onPausedChanged: { + if (root.paused) + brain.stop() + else if (root.demo === "" && root.action !== "walk") + brain.restart() + } + + NumberAnimation { + id: stroll + target: root + property: "catX" + easing.type: Easing.Linear + paused: root.paused && running + onFinished: if (root.demo === "") root.setAction("idle") + } + + // The walk cycle, only while walking. + NumberAnimation { + target: rig + property: "walkPhase" + from: 0 + to: Math.PI * 2 + duration: 560 + loops: Animation.Infinite + running: root.action === "walk" && !root.paused && root.demo === "" + } + + // A few lazy tail swishes whenever the cat settles, then stillness. + SequentialAnimation { + id: tailSway + loops: 3 + running: false + paused: root.paused && running + NumberAnimation { target: rig; property: "tailSway"; to: 14; duration: 900; easing.type: Easing.InOutSine } + NumberAnimation { target: rig; property: "tailSway"; to: -10; duration: 1100; easing.type: Easing.InOutSine } + NumberAnimation { target: rig; property: "tailSway"; to: 0; duration: 700; easing.type: Easing.InOutSine } + } + + // Asleep: breathing in three coarse steps, like everything else in a + // blocky world -- and a redraw only a few times a second. + Timer { + interval: 420 + repeat: true + running: root.action === "sleep" && !root.paused + property int step: 0 + onTriggered: { + step = (step + 1) % 6 + rig.breath = [0, 0.5, 1, 1, 0.5, 0][step] + } + } + + // An ear flick now and then while awake and still. + Timer { + interval: 4200 + repeat: true + running: (root.action === "idle" || root.action === "loaf") && !root.paused + onTriggered: if (Math.random() < 0.45) earFlick.restart() + } + SequentialAnimation { + id: earFlick + NumberAnimation { target: rig; property: "earTwitch"; to: 1; duration: 70 } + NumberAnimation { target: rig; property: "earTwitch"; to: 0; duration: 160 } + } + + // -- Looking at the pointer --------------------------------------------- + // Passive: follows the pointer anywhere over the page without taking a + // single click from what is underneath. + HoverHandler { + id: pointer + enabled: !root.paused + } + readonly property real headX: stage.x + stage.width / 2 + root.facing * 40 + readonly property real headY: stage.y + stage.height * 0.45 + readonly property bool watching: pointer.hovered && root.action !== "sleep" && root.action !== "walk" + && Math.abs(pointer.point.position.x - root.headX) < 360 + Binding { + target: rig + property: "headYaw" + value: { + if (!root.watching) + return 0 + // Towards the viewer when the pointer is near, towards the + // pointer's side otherwise -- the head turns, the body stays. + const dx = pointer.point.position.x - root.headX + return Math.max(-40, Math.min(40, dx * 0.12 * -root.facing)) + 20 * root.facing + } + } + Binding { + target: rig + property: "headPitch" + value: root.watching + ? Math.max(-22, Math.min(28, (root.headY - pointer.point.position.y) * 0.1)) + : 0 + } + + // -- The stage: a small 3D viewport standing on the floor --------------- + Item { + id: stage + width: 190 + height: 130 + // The camera below puts the model's floor (y = 0) at 89 px down the + // viewport, so this rests the paws on the bottom edge. + x: root.catX - width / 2 + purr.offset + y: root.height - 89 - 1 - hop.lift + + View3D { + id: view + anchors.fill: parent + renderMode: View3D.Offscreen + environment: SceneEnvironment { + backgroundMode: SceneEnvironment.Transparent + antialiasingMode: SceneEnvironment.MSAA + antialiasingQuality: SceneEnvironment.High + tonemapMode: SceneEnvironment.TonemapModeNone + } + + PerspectiveCamera { + position: Qt.vector3d(0, 0.9, 5.2) + eulerRotation.x: -6 + fieldOfView: 20 + clipNear: 0.5 + clipFar: 20 + } + DirectionalLight { + eulerRotation: Qt.vector3d(-55, -25, 0) + brightness: 1.05 + ambientColor: Qt.rgba(0.42, 0.42, 0.46, 1) + } + DirectionalLight { + eulerRotation: Qt.vector3d(-15, 160, 0) + brightness: 0.35 + } + + CatRig { + id: rig + texture: "textures/cat_" + root.variant + ".png" + eulerRotation.y: root.action === "sleep" ? root.facingYaw * 0.8 : root.facingYaw + } + } + + MouseArea { + id: catMouse + anchors.horizontalCenter: parent.horizontalCenter + y: 30 + width: 120 + height: 62 + enabled: !root.paused + cursorShape: Qt.PointingHandCursor + acceptedButtons: Qt.LeftButton + onClicked: root.pet() + onDoubleClicked: hop.start() + Accessible.role: Accessible.Button + Accessible.name: qsTr("Pet the cat") + } + + // Hearts rise from the cat when petted. + Repeater { + id: hearts + model: 3 + delegate: Image { + id: heart + required property int index + source: "textures/heart.png" + smooth: false + width: 12 + height: 12 + x: stage.width / 2 - 6 + (index - 1) * 16 + y: 34 + opacity: 0 + property real rise: 0 + transform: Translate { y: -heart.rise } + SequentialAnimation { + id: riseAnim + PauseAnimation { duration: heart.index * 140 } + ParallelAnimation { + NumberAnimation { target: heart; property: "rise"; from: 0; to: 34; duration: 1100; easing.type: Easing.OutQuad } + SequentialAnimation { + NumberAnimation { target: heart; property: "opacity"; to: 1; duration: 160 } + PauseAnimation { duration: 560 } + NumberAnimation { target: heart; property: "opacity"; to: 0; duration: 380 } + } + } + } + // Each pet() bumps root.petPulse; every heart starts its own + // staggered float from that, no method call across the + // delegate boundary needed. + Connections { + target: root + function onPetPulseChanged() { riseAnim.restart() } + } + } + } + + // Asleep: a small "z" drifts up every few seconds. + Image { + id: zee + source: "textures/sleep_z.png" + smooth: false + width: 10 + height: 10 + x: stage.width / 2 + root.facing * 36 + y: 40 - zee.rise + opacity: 0 + property real rise: 0 + SequentialAnimation { + id: zeeFloat + ParallelAnimation { + NumberAnimation { target: zee; property: "rise"; from: 0; to: 22; duration: 1800 } + SequentialAnimation { + NumberAnimation { target: zee; property: "opacity"; to: 0.9; duration: 300 } + PauseAnimation { duration: 1000 } + NumberAnimation { target: zee; property: "opacity"; to: 0; duration: 500 } + } + } + } + Timer { + interval: 3800 + repeat: true + running: root.action === "sleep" && !root.paused + onTriggered: zeeFloat.restart() + } + } + } + + // -- Petting and hopping -------------------------------------------------- + function pet() { + if (root.action === "walk") { + stroll.stop() + } + if (root.action === "sleep") + root.setAction("loaf") + else if (root.action !== "loaf") + root.setAction("idle") + root.petPulse += 1 + purr.start() + brain.interval = 5000 + brain.restart() + } + + // A purr: the cat shivers by a pixel for a moment. + QtObject { + id: purr + property real offset: 0 + property int ticks: 0 + function start() { ticks = 30; purrTimer.restart() } + } + Timer { + id: purrTimer + interval: 45 + repeat: true + onTriggered: { + purr.ticks -= 1 + purr.offset = purr.ticks > 0 ? (purr.ticks % 2 ? 0.7 : -0.7) : 0 + if (purr.ticks <= 0) + stop() + } + } + + QtObject { + id: hop + property real lift: 0 + function start() { + if (root.action === "sleep" || hopAnim.running) + return + stroll.stop() + root.setAction("idle") + hopAnim.restart() + } + } + SequentialAnimation { + id: hopAnim + NumberAnimation { target: rig; property: "loaf"; to: 0.35; duration: 120 } + ParallelAnimation { + NumberAnimation { target: rig; property: "loaf"; to: 0; duration: 140 } + NumberAnimation { target: hop; property: "lift"; to: 26; duration: 260; easing.type: Easing.OutQuad } + } + NumberAnimation { target: hop; property: "lift"; to: 0; duration: 240; easing.type: Easing.InQuad } + NumberAnimation { target: rig; property: "loaf"; to: 0.25; duration: 80 } + NumberAnimation { target: rig; property: "loaf"; to: 0; duration: 160 } + } + + // -- Demo poses ----------------------------------------------------------- + onDemoChanged: applyDemo() + Component.onCompleted: { + if (root.demo !== "") + applyDemo() + else + root.setAction("idle") + } + function applyDemo() { + if (root.demo === "") + return + stroll.stop() + brain.stop() + root.catX = root.width * 0.6 + root.facing = 1 + root.setAction(root.demo === "pet" ? "idle" : root.demo) + brain.stop() + if (root.demo === "walk") + rig.walkPhase = Math.PI / 2 + if (root.demo === "sleep") + rig.breath = 1 + if (root.demo === "pet") + Qt.callLater(root.pet) + } +} diff --git a/launcher/qml/Cat/CatRig.qml b/launcher/qml/Cat/CatRig.qml new file mode 100644 index 00000000..b34690dc --- /dev/null +++ b/launcher/qml/Cat/CatRig.qml @@ -0,0 +1,191 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick3D + +/* + * The cat's skeleton: the parts of assets/cat.glb (see assets/CREDITS.md) + * regrouped around real joints so they can move. + * + * The model as exported puts every leg's pivot at the head, which is fine + * for a still pose and useless for walking. Here each part hangs from its + * own joint instead -- neck, ear bases, hips and shoulders, tail base and + * the bend between the two tail segments -- and the pose is driven by a + * handful of plain numbers (walkPhase, loaf, sleep, headYaw, ...) that + * CatOverlay.qml animates. Coordinates are the model's own: 1/16 of a unit + * per texture pixel, y up, the head towards -z; `px()` keeps the joint + * positions readable in those pixels. + * + * Parts: body (object_0), head (object_1) with its ears (object_2/3) and + * nose (object_4), legs front-left/front-right/back-left/back-right + * (object_5..8), tail tip (object_9) and tail base (object_10). + */ +Node { + id: rig + + // The coat, a 64x64 texture in the model's own UV layout. + property url texture: "textures/cat_calico.png" + + // -- Pose inputs, all driven from CatOverlay.qml ------------------------ + // Walk cycle angle (radians, grows while walking) and how much of the + // walk shows (0 = standing, 1 = full stride), so starting and stopping + // blend instead of snapping. + property real walkPhase: 0 + property real walkAmount: 0 + // Where the head looks, in degrees: yaw left/right, pitch up/down. + property real headYaw: 0 + property real headPitch: 0 + // 0..1 lying down with the legs folded under ("loaf"), and 0..1 asleep + // on top of that (head down and turned, tail curled round). + property real loaf: 0 + property real sleep: 0 + // 0..1 the front-down, rear-up stretch. + property real stretch: 0 + // Slow tail sway (degrees) for idling, added on top of the walk's own. + property real tailSway: 0 + // 0..1 one ear flick. + property real earTwitch: 0 + // 0..1 breathing, a tiny lift of the chest. + property real breath: 0 + + function px(v) { return v / 16 } + + readonly property real stride: Math.sin(rig.walkPhase) * 30 * rig.walkAmount + readonly property real bob: Math.abs(Math.sin(rig.walkPhase)) * 0.35 * rig.walkAmount + // Lying down, the legs fold flat and the whole body drops by their + // length (4 px) minus a little so the belly rests on the ground. + readonly property real drop: 3.4 * Math.max(rig.loaf, rig.sleep) + readonly property real folded: 82 * Math.max(rig.loaf, rig.sleep) + + PrincipledMaterial { + id: coat + roughness: 1 + metalness: 0 + cullMode: PrincipledMaterial.NoCulling + alphaMode: PrincipledMaterial.Mask + alphaCutoff: 0.05 + baseColorMap: Texture { + source: rig.texture + generateMipmaps: false + magFilter: Texture.Nearest + minFilter: Texture.Nearest + mipFilter: Texture.None + tilingModeHorizontal: Texture.ClampToEdge + tilingModeVertical: Texture.ClampToEdge + } + } + + // Everything below is laid out in the model's coordinates; this moves + // the middle of the body (x -1 px, z 3 px) onto the rig's origin, and + // lowers it when lying down or lifts it for the walk's bob. + Node { + position: Qt.vector3d(rig.px(1), rig.px(rig.bob - rig.drop), rig.px(-3)) + + // Body, pivoting at the back hips for the stretch. + Node { + readonly property vector3d joint: Qt.vector3d(rig.px(-1), rig.px(4), rig.px(10)) + id: body + position: joint + eulerRotation.x: -14 * rig.stretch + scale: Qt.vector3d(1, 1 + 0.02 * rig.breath, 1) + Node { + position: body.joint.times(-1) + Model { source: "model/meshes/object_0_mesh.mesh"; materials: [coat] } + } + } + + // Head, pivoting at the neck; ducks with the stretch, tucks in sleep. + Node { + readonly property vector3d joint: Qt.vector3d(rig.px(-1), rig.px(8.5), rig.px(-5)) + id: head + position: joint.plus(Qt.vector3d(0, rig.px(-3 * rig.stretch - 0.8 * rig.sleep), 0)) + eulerRotation: Qt.vector3d(rig.headPitch - 18 * rig.sleep + 10 * rig.stretch, + rig.headYaw + 34 * rig.sleep, + -6 * rig.sleep) + Node { + position: head.joint.times(-1) + Model { source: "model/meshes/object_1_mesh.mesh"; materials: [coat] } + Model { source: "model/meshes/object_4_mesh.mesh"; materials: [coat] } + Node { + readonly property vector3d joint: Qt.vector3d(rig.px(-2.75), rig.px(11), rig.px(-6)) + id: earLeft + position: joint + eulerRotation.z: 22 * rig.earTwitch + Node { + position: earLeft.joint.times(-1) + Model { source: "model/meshes/object_2_mesh.mesh"; materials: [coat] } + } + } + Node { + readonly property vector3d joint: Qt.vector3d(rig.px(0.75), rig.px(11), rig.px(-6)) + id: earRight + position: joint + Node { + position: earRight.joint.times(-1) + Model { source: "model/meshes/object_3_mesh.mesh"; materials: [coat] } + } + } + } + } + + // Legs hang from their hips/shoulders; the mesh origins already sit + // there, so no counter-offset is needed. Diagonal pairs move + // together, like a real trot; lying down folds front legs forward + // and back legs back. + Node { + position: Qt.vector3d(rig.px(-2.25), rig.px(4), rig.px(-2)) + eulerRotation.x: rig.stride + rig.folded + 30 * rig.stretch + Model { source: "model/meshes/object_5_mesh.mesh"; materials: [coat] } + } + Node { + position: Qt.vector3d(rig.px(0.25), rig.px(4), rig.px(-2)) + eulerRotation.x: -rig.stride + rig.folded + 30 * rig.stretch + Model { source: "model/meshes/object_6_mesh.mesh"; materials: [coat] } + } + Node { + position: Qt.vector3d(rig.px(-2.25), rig.px(4), rig.px(9)) + eulerRotation.x: -rig.stride - rig.folded + Model { source: "model/meshes/object_7_mesh.mesh"; materials: [coat] } + } + Node { + position: Qt.vector3d(rig.px(0.25), rig.px(4), rig.px(9)) + eulerRotation.x: rig.stride - rig.folded + Model { source: "model/meshes/object_8_mesh.mesh"; materials: [coat] } + } + + // Tail: the base segment pivots where it leaves the body and carries + // the tip, which bends again at its own joint -- so a sway ripples + // down the tail instead of swinging it like a stick. Asleep, it + // curls forward along the body. + Node { + readonly property vector3d joint: Qt.vector3d(0.03125, 0.59375, 0.6875) + id: tailBase + position: joint + eulerRotation: Qt.vector3d(-12 * rig.loaf + 16 * rig.stretch, + rig.tailSway + Math.sin(rig.walkPhase * 0.5) * 10 * rig.walkAmount + + 70 * rig.sleep, + 0) + // The base segment's own tilt from the export (30 degrees about x). + Node { + rotation: Qt.quaternion(0.965926, 0.258819, 0, 0) + Model { source: "model/meshes/object_10_mesh.mesh"; materials: [coat] } + } + Node { + readonly property vector3d joint: Qt.vector3d(rig.px(-0.75), rig.px(5.75), rig.px(17)) + id: tailTip + position: joint.minus(tailBase.joint) + eulerRotation: Qt.vector3d(8 * rig.loaf, + rig.tailSway * 0.8 + + Math.sin(rig.walkPhase * 0.5 - 0.8) * 14 * rig.walkAmount + + 60 * rig.sleep, + 0) + Node { + position: tailTip.joint.times(-1) + Model { source: "model/meshes/object_9_mesh.mesh"; materials: [coat] } + } + } + } + } +} diff --git a/launcher/qml/Cat/assets/CREDITS.md b/launcher/qml/Cat/assets/CREDITS.md new file mode 100644 index 00000000..a4258010 --- /dev/null +++ b/launcher/qml/Cat/assets/CREDITS.md @@ -0,0 +1,14 @@ +# Cat model credits + +`cat.glb`, the Qt Quick 3D meshes generated from it in `../model/` (with +Qt's `balsam` tool) and its texture `../textures/cat_calico.png` are +**"Minecraft Cat"** by **JanesBT** (https://sketchfab.com/JanesBt), +published at +https://sketchfab.com/3d-models/minecraft-cat-2b59a1815b1e47a298d4a3c084523d72 +under the Creative Commons Attribution 4.0 International licence +(CC BY 4.0, http://creativecommons.org/licenses/by/4.0/). + +Changes made for MeshMC: the parts are regrouped around their joints for +animation (`../CatRig.qml`), and the other coats (`../textures/cat_ginger.png`, +`cat_black.png`, `cat_white.png`, `cat_siamese.png`) are recoloured from the +original texture by `../tools/gen_cat_variants.py`. diff --git a/launcher/qml/Cat/assets/cat.glb b/launcher/qml/Cat/assets/cat.glb new file mode 100644 index 00000000..08cabafe Binary files /dev/null and b/launcher/qml/Cat/assets/cat.glb differ diff --git a/launcher/qml/Cat/model/meshes/object_0_mesh.mesh b/launcher/qml/Cat/model/meshes/object_0_mesh.mesh new file mode 100644 index 00000000..38bd66ed Binary files /dev/null and b/launcher/qml/Cat/model/meshes/object_0_mesh.mesh differ diff --git a/launcher/qml/Cat/model/meshes/object_10_mesh.mesh b/launcher/qml/Cat/model/meshes/object_10_mesh.mesh new file mode 100644 index 00000000..4aa15edf Binary files /dev/null and b/launcher/qml/Cat/model/meshes/object_10_mesh.mesh differ diff --git a/launcher/qml/Cat/model/meshes/object_1_mesh.mesh b/launcher/qml/Cat/model/meshes/object_1_mesh.mesh new file mode 100644 index 00000000..138ce52b Binary files /dev/null and b/launcher/qml/Cat/model/meshes/object_1_mesh.mesh differ diff --git a/launcher/qml/Cat/model/meshes/object_2_mesh.mesh b/launcher/qml/Cat/model/meshes/object_2_mesh.mesh new file mode 100644 index 00000000..ba2841cb Binary files /dev/null and b/launcher/qml/Cat/model/meshes/object_2_mesh.mesh differ diff --git a/launcher/qml/Cat/model/meshes/object_3_mesh.mesh b/launcher/qml/Cat/model/meshes/object_3_mesh.mesh new file mode 100644 index 00000000..1e6bff84 Binary files /dev/null and b/launcher/qml/Cat/model/meshes/object_3_mesh.mesh differ diff --git a/launcher/qml/Cat/model/meshes/object_4_mesh.mesh b/launcher/qml/Cat/model/meshes/object_4_mesh.mesh new file mode 100644 index 00000000..d0731b5b Binary files /dev/null and b/launcher/qml/Cat/model/meshes/object_4_mesh.mesh differ diff --git a/launcher/qml/Cat/model/meshes/object_5_mesh.mesh b/launcher/qml/Cat/model/meshes/object_5_mesh.mesh new file mode 100644 index 00000000..8a319b21 Binary files /dev/null and b/launcher/qml/Cat/model/meshes/object_5_mesh.mesh differ diff --git a/launcher/qml/Cat/model/meshes/object_6_mesh.mesh b/launcher/qml/Cat/model/meshes/object_6_mesh.mesh new file mode 100644 index 00000000..e00fd606 Binary files /dev/null and b/launcher/qml/Cat/model/meshes/object_6_mesh.mesh differ diff --git a/launcher/qml/Cat/model/meshes/object_7_mesh.mesh b/launcher/qml/Cat/model/meshes/object_7_mesh.mesh new file mode 100644 index 00000000..b0420e26 Binary files /dev/null and b/launcher/qml/Cat/model/meshes/object_7_mesh.mesh differ diff --git a/launcher/qml/Cat/model/meshes/object_8_mesh.mesh b/launcher/qml/Cat/model/meshes/object_8_mesh.mesh new file mode 100644 index 00000000..f9b41382 Binary files /dev/null and b/launcher/qml/Cat/model/meshes/object_8_mesh.mesh differ diff --git a/launcher/qml/Cat/model/meshes/object_9_mesh.mesh b/launcher/qml/Cat/model/meshes/object_9_mesh.mesh new file mode 100644 index 00000000..fe3e1080 Binary files /dev/null and b/launcher/qml/Cat/model/meshes/object_9_mesh.mesh differ diff --git a/launcher/qml/Cat/textures/cat_black.png b/launcher/qml/Cat/textures/cat_black.png new file mode 100644 index 00000000..36b77099 Binary files /dev/null and b/launcher/qml/Cat/textures/cat_black.png differ diff --git a/launcher/qml/Cat/textures/cat_calico.png b/launcher/qml/Cat/textures/cat_calico.png new file mode 100644 index 00000000..e65daaed Binary files /dev/null and b/launcher/qml/Cat/textures/cat_calico.png differ diff --git a/launcher/qml/Cat/textures/cat_ginger.png b/launcher/qml/Cat/textures/cat_ginger.png new file mode 100644 index 00000000..7358344c Binary files /dev/null and b/launcher/qml/Cat/textures/cat_ginger.png differ diff --git a/launcher/qml/Cat/textures/cat_siamese.png b/launcher/qml/Cat/textures/cat_siamese.png new file mode 100644 index 00000000..cb0c12ad Binary files /dev/null and b/launcher/qml/Cat/textures/cat_siamese.png differ diff --git a/launcher/qml/Cat/textures/cat_white.png b/launcher/qml/Cat/textures/cat_white.png new file mode 100644 index 00000000..5971eae2 Binary files /dev/null and b/launcher/qml/Cat/textures/cat_white.png differ diff --git a/launcher/qml/Cat/textures/heart.png b/launcher/qml/Cat/textures/heart.png new file mode 100644 index 00000000..45ac3119 Binary files /dev/null and b/launcher/qml/Cat/textures/heart.png differ diff --git a/launcher/qml/Cat/textures/sleep_z.png b/launcher/qml/Cat/textures/sleep_z.png new file mode 100644 index 00000000..55b63dc1 Binary files /dev/null and b/launcher/qml/Cat/textures/sleep_z.png differ diff --git a/launcher/qml/Cat/tools/gen_cat_variants.py b/launcher/qml/Cat/tools/gen_cat_variants.py new file mode 100644 index 00000000..7551eb24 --- /dev/null +++ b/launcher/qml/Cat/tools/gen_cat_variants.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 Project Tick +# SPDX-FileContributor: Project Tick +# SPDX-License-Identifier: Apache-2.0 +"""Recolours the cat model's own texture (textures/cat_calico.png, from +assets/cat.glb -- see assets/CREDITS.md) into the other coat variants. + +Every pixel is sorted into a coat class by its colour -- light grey fur, +orange/cream patches, brown, dark patches -- and repainted with that +variant's colour for the class, keeping each pixel's relative brightness so +the original shading survives. Eyes, the nose and the paw pads are left +alone except where a coat needs different eyes to stay visible. + + python3 tools/gen_cat_variants.py (run from launcher/qml/Cat) +""" +import colorsys +from pathlib import Path + +from PIL import Image + +HERE = Path(__file__).resolve().parent.parent +SRC = HERE / "textures" / "cat_calico.png" + +# Face row with the eyes (x, y) -> eye pixel; recoloured per variant only. +EYES = {(6, 35): "pupil", (8, 35): "pupil", (9, 35): "iris", (5, 35): "iris"} +KEEP = {(169, 116, 116), (248, 221, 114)} # nose, paw pads + +VARIANTS = { + # light fur patches cream brown dark pupil iris + "ginger": ((236, 166, 92), (200, 112, 48), (245, 205, 150), (176, 104, 52), (150, 82, 36), (40, 30, 20), (120, 170, 60)), + "black": ((50, 48, 54), (40, 38, 44), (62, 60, 66), (38, 36, 42), (26, 25, 30), (214, 222, 70), (170, 210, 60)), + "white": ((236, 236, 236), (222, 216, 208), (242, 234, 222), (200, 196, 190), (172, 168, 162), (30, 30, 34), (82, 171, 188)), + "siamese": ((238, 226, 204), (214, 196, 168), (242, 232, 214), (120, 96, 74), (84, 66, 52), (30, 40, 80), (70, 130, 210)), +} + + +def coat_class(rgb): + r, g, b = (c / 255 for c in rgb) + h, l, s = colorsys.rgb_to_hls(r, g, b) + if l < 0.30: + return "dark", l + if s < 0.12: + return "light", l + if l > 0.68: + return "cream", l + if h * 360 < 55 and s > 0.35: + return "patch", l + return "brown", l + + +REF = {"light": 200 / 255 * 0.99, "patch": 0.54, "cream": 0.70, "brown": 0.46, "dark": 0.23} + + +def shade(colour, l, ref): + k = max(0.6, min(1.4, l / ref if ref else 1.0)) + return tuple(max(0, min(255, round(c * k))) for c in colour) + + +def main(): + src = Image.open(SRC).convert("RGBA") + for name, (light, patch, cream, brown, dark, pupil, iris) in VARIANTS.items(): + out = src.copy() + table = {"light": light, "patch": patch, "cream": cream, "brown": brown, "dark": dark} + for y in range(src.height): + for x in range(src.width): + r, g, b, a = src.getpixel((x, y)) + if a == 0 or (r, g, b) in KEEP: + continue + eye = EYES.get((x, y)) + if eye: + out.putpixel((x, y), (*(pupil if eye == "pupil" else iris), a)) + continue + cls, l = coat_class((r, g, b)) + out.putpixel((x, y), (*shade(table[cls], l, REF[cls]), a)) + out.save(HERE / "textures" / f"cat_{name}.png") + print("wrote", f"cat_{name}.png") + + +if __name__ == "__main__": + main() diff --git a/launcher/qml/Components/AccountChip.qml b/launcher/qml/Components/AccountChip.qml new file mode 100644 index 00000000..a7a8239e --- /dev/null +++ b/launcher/qml/Components/AccountChip.qml @@ -0,0 +1,127 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import MeshMC.Theme + +/* + * The signed-in account at the foot of the sidebar. With no account it + * turns into a sign-in prompt instead of showing a fake "Guest": playing + * online needs an account, so that is the one thing worth saying here. + */ +AbstractButton { + id: control + + property string name + // "Microsoft", "Offline" or "" when there is no account. + property string kind + property string avatarSource + + readonly property bool signedIn: name.length > 0 + + implicitHeight: Theme.control.heightLg + Theme.space.md + implicitWidth: 200 + hoverEnabled: true + + Accessible.name: signedIn ? qsTr("Account: %1").arg(name) : qsTr("Sign in") + + background: Rectangle { + radius: Theme.radius.lg + color: control.hovered ? Theme.palette.surfaceRaised : Theme.palette.surface + border.width: 1 + border.color: Theme.palette.border + Behavior on color { ColorAnimation { duration: Theme.motion.fast; easing.type: Theme.motion.easing } } + } + + contentItem: Item { + Rectangle { + id: avatar + anchors.left: parent.left + anchors.leftMargin: Theme.space.sm + 2 + anchors.verticalCenter: parent.verticalCenter + width: Theme.control.height - 4 + height: width + radius: width / 2 + color: control.signedIn ? Theme.palette.accentSubtle : Theme.palette.surfaceOverlay + border.width: 1 + border.color: Theme.palette.border + + Text { + anchors.centerIn: parent + visible: control.signedIn + text: control.name.charAt(0).toUpperCase() + color: Theme.palette.accent + font.family: Theme.font.family + font.pixelSize: Theme.type.title.pixelSize + font.weight: Font.Bold + } + + Image { + id: avatarImage + anchors.fill: parent + anchors.margins: 1 + // Drawn over the initial: an account without a skin comes + // back transparent and the initial shows through. Sampled + // well above display size and downscaled by the GPU + // (smooth: false keeps that downscale a crisp nearest- + // neighbour one) so the face stays sharp regardless of the + // chip's own size or the display's pixel ratio. + source: control.avatarSource + visible: control.signedIn && control.avatarSource.length > 0 + smooth: false + sourceSize: Qt.size(64, 64) + } + + MeshIcon { + anchors.centerIn: parent + visible: !control.signedIn + iconName: "user" + size: Theme.icon.md + color: Theme.palette.textSecondary + } + } + + Column { + anchors.left: avatar.right + anchors.leftMargin: Theme.space.sm + 2 + anchors.right: chevron.left + anchors.rightMargin: Theme.space.xs + anchors.verticalCenter: parent.verticalCenter + spacing: 1 + + Text { + width: parent.width + text: control.signedIn ? control.name : qsTr("Sign in") + elide: Text.ElideRight + color: Theme.palette.textPrimary + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + font.weight: Font.DemiBold + } + + Text { + width: parent.width + text: !control.signedIn ? qsTr("Not signed in") + : control.kind === "Microsoft" ? qsTr("Microsoft account") + : control.kind === "Offline" ? qsTr("Offline account") + : control.kind + elide: Text.ElideRight + color: Theme.palette.textTertiary + font.family: Theme.font.family + font.pixelSize: Theme.type.caption.pixelSize + } + } + + MeshIcon { + id: chevron + anchors.right: parent.right + anchors.rightMargin: Theme.space.sm + anchors.verticalCenter: parent.verticalCenter + iconName: "chevron-right" + size: Theme.icon.sm + color: control.hovered ? Theme.palette.textSecondary : Theme.palette.textTertiary + } + } +} diff --git a/launcher/qml/Components/AccountRow.qml b/launcher/qml/Components/AccountRow.qml new file mode 100644 index 00000000..23c3580f --- /dev/null +++ b/launcher/qml/Components/AccountRow.qml @@ -0,0 +1,169 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import MeshMC.Theme + +/* + * One account in the Accounts page's "other accounts" list: face, name, + * what kind it is and whether it still works, and the account actions. The + * default account -- the one games launch with -- gets the accent border; + * any other can be made default in one click. + * + * The page hides whichever row is shown big in its hero instead (see + * `hidden`), so this file never needs to know what a hero is. + */ +Item { + id: root + + required property int index + required property string profileName + required property string name + required property bool isDefault + required property bool isMSA + required property string stateKey + required property string status + required property string accountId + + // Set by the page for whichever row it is already showing in the hero + // card, so the same account is never listed twice. + property bool hidden: false + // Bumped by the page whenever an account's skin may have changed, so the + // face image's url changes and QML actually refetches it -- see + // AccountsPage.qml's imageRevision. + property int rev: 0 + + signal makeDefaultRequested() + signal refreshRequested() + signal removeRequested() + signal manageSkinRequested() + + readonly property string shownName: profileName.length > 0 ? profileName : name + readonly property bool hovered: !root.hidden && hoverHandler.hovered + + function stateTone(key) { + switch (key) { + case "online": return "success" + case "working": return "info" + case "errored": case "gone": return "danger" + case "expired": return "warning" + default: return "neutral" + } + } + + // A hidden row collapses out of the ListView entirely rather than just + // turning invisible, so it leaves no gap where it used to be. + implicitHeight: root.hidden ? 0 : 72 + visible: !root.hidden + + HoverHandler { id: hoverHandler; enabled: !root.hidden } + + Rectangle { + id: card + width: parent.width + height: parent.height + radius: Theme.radius.lg + color: root.isDefault ? Theme.palette.surfaceRaised + : root.hovered ? Theme.palette.surfaceRaised : Theme.palette.surface + border.width: root.isDefault ? 2 : 1 + border.color: root.isDefault ? Theme.palette.accent + : root.hovered ? Theme.palette.borderStrong : Theme.palette.border + // A small lift on hover, same idiom as the library's InstanceCard. + y: root.hovered ? -2 : 0 + + Behavior on y { NumberAnimation { duration: Theme.motion.fast; easing.type: Theme.motion.easing } } + Behavior on color { ColorAnimation { duration: Theme.motion.fast; easing.type: Theme.motion.easing } } + Behavior on border.color { ColorAnimation { duration: Theme.motion.fast; easing.type: Theme.motion.easing } } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: Theme.space.md + anchors.rightMargin: Theme.space.md + spacing: Theme.space.md + + Rectangle { + id: face + Layout.preferredWidth: 40 + Layout.preferredHeight: 40 + radius: width / 2 + color: Theme.palette.accentSubtle + border.width: 1 + border.color: Theme.palette.border + + Text { + anchors.centerIn: parent + text: root.shownName.charAt(0).toUpperCase() + color: Theme.palette.accent + font.family: Theme.font.family + font.pixelSize: Theme.type.title.pixelSize + font.weight: Font.Bold + } + // Drawn over the initial; an account without a skin comes + // back transparent and the initial shows through. + Image { + anchors.fill: parent + anchors.margins: 1 + source: !root.hidden && root.accountId.length > 0 + ? "image://accountface/" + root.accountId + "?rev=" + root.rev : "" + sourceSize: Qt.size(80, 80) + smooth: false + } + } + + Column { + Layout.fillWidth: true + Layout.minimumWidth: 0 + spacing: Theme.space.xxs + + Text { + width: parent.width + text: root.shownName + elide: Text.ElideRight + color: Theme.palette.textPrimary + font.family: Theme.font.family + font.pixelSize: Theme.type.bodyStrong.pixelSize + font.weight: Theme.type.bodyStrong.weight + } + + Row { + spacing: Theme.space.sm + Tag { + iconName: root.isMSA ? "user" : "users" + text: root.isMSA ? qsTr("Microsoft") : qsTr("Offline") + } + StatusBadge { + anchors.verticalCenter: parent.verticalCenter + visible: root.isMSA && root.status.length > 0 + tone: root.stateTone(root.stateKey) + text: root.status + } + } + } + + Button { + visible: !root.isDefault + text: qsTr("Use this account") + onClicked: root.makeDefaultRequested() + } + IconButton { + iconName: "image" + tip: root.isMSA ? qsTr("Manage skin & cape") : qsTr("Skins need a Microsoft account") + onClicked: root.manageSkinRequested() + } + IconButton { + visible: root.isMSA + iconName: "refresh" + tip: qsTr("Sign in again") + onClicked: root.refreshRequested() + } + IconButton { + iconName: "trash" + tip: qsTr("Remove account") + onClicked: root.removeRequested() + } + } + } +} diff --git a/launcher/qml/Components/AccountsPage.qml b/launcher/qml/Components/AccountsPage.qml new file mode 100644 index 00000000..f5bd18bc --- /dev/null +++ b/launcher/qml/Components/AccountsPage.qml @@ -0,0 +1,633 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import MeshMC.Theme + +/* + * The accounts games are launched with. Microsoft sign-in happens in the + * browser; this page says so while it waits, and can reopen the page if + * the browser tab was lost. Offline accounts need a Microsoft account that + * owns the game first -- the same rule the classic page enforced. + * + * A hero at the top shows the account games actually launch with -- full + * body, standing on a small "stage" -- the same way the library's + * ContinueCard/InstancePage hero leads with the instance that matters most. + * Every other account is a compact row below it. + */ +Item { + id: root + + // AccountsController: accounts, hasDefault, setDefault, remove, + // refresh, addOffline, loginMicrosoft. + property var controller: null + // MicrosoftLoginController of the sign-in in progress, if any. Held + // here, on a page that is never destroyed, for as long as it runs. + property var login: null + + readonly property var accounts: controller ? controller.accounts : null + + // Bumped whenever any account's data changes (a refresh landing, a skin + // upload finishing...), so the "image://accountface/..." urls below + // change and QML actually refetches them -- it caches by url, and + // nothing about the url itself would otherwise say a skin changed. + property int imageRevision: 0 + Connections { + target: root.accounts + function onDataChanged() { root.imageRevision++ } + } + + function openSkinEditor(row, accountId, accountName) { + if (!root.controller) + return + skinCapeEditor.row = row + skinCapeEditor.accountId = accountId + skinCapeEditor.accountName = accountName || "" + skinCapeEditor.open() + } + + // qml-preview-tools has only offline accounts to snapshot with, so no + // real row's isMSA role ever lets the skin/cape editor open on its own; + // MESHMC_QML_ROUTE containing "accounts-demo" opens it anyway, filled + // with AccountsController::accountSkinInfo(-1)'s canned demo data. See + // AccountsController::skinDemoRequested()'s own comment for why -1. + Component.onCompleted: { + if (root.controller && root.controller.skinDemoRequested) + root.openSkinEditor(-1, "", qsTr("Demo")) + } + function startMicrosoftLogin() { + if (!root.controller) + return + root.login = root.controller.loginMicrosoft() + loginDialog.open() + } + + /* + * Which row is the default account, found by watching every row's own + * isDefault role rather than asking AccountList for one directly: it + * exposes the flag per row (for the list delegate below) but not as a + * "here is the default index" query of its own. A Repeater is the + * cheapest way to look at every row without paging through count()/ + * data() by hand -- each probe is a zero-size Item, never drawn. + */ + property int defaultIndex: -1 + Repeater { + model: root.accounts + delegate: Item { + id: probe + required property int index + required property bool isDefault + // Everything the hero card shows, so it can read this probe + // instead of the list's currentItem -- which ListView does not + // reliably create for a row it lays out collapsed. + required property string profileName + required property string name + required property bool isMSA + required property string stateKey + required property string status + required property string accountId + readonly property bool isHero: index === root.heroIndex + onIsHeroChanged: claimHero() + function claimHero() { + if (isHero) + root.heroItem = probe + else if (root.heroItem === probe) + root.heroItem = null + } + Component.onDestruction: if (root.heroItem === probe) root.heroItem = null + visible: false + width: 0 + height: 0 + // Re-asserts on either role change (isDefault flips, e.g. the + // previous default was just removed) or a plain row-index shift + // (an unrelated row above this one was removed/inserted) -- + // either can leave a stale index behind otherwise. + function report() { + if (isDefault) + root.defaultIndex = index + else if (root.defaultIndex === index) + root.defaultIndex = -1 + } + onIsDefaultChanged: report() + onIndexChanged: report() + Component.onCompleted: { report(); claimHero() } + } + } + // Falls back to the first row so the hero still has someone to show + // right after the very first account is added, before it is flagged + // default. + readonly property int heroIndex: root.defaultIndex >= 0 ? root.defaultIndex + : list.count > 0 ? 0 : -1 + // The probe above for the hero's row (see its claimHero()). + property var heroItem: null + function stateTone(key) { + switch (key) { + case "online": return "success" + case "working": return "info" + case "errored": case "gone": return "danger" + case "expired": return "warning" + default: return "neutral" + } + } + readonly property bool heroIsMSA: !!heroItem && heroItem.isMSA + readonly property bool heroIsDefault: !!heroItem && heroItem.isDefault + readonly property string heroName: heroItem + ? (heroItem.profileName.length > 0 ? heroItem.profileName : heroItem.name) : "" + readonly property string heroAccountId: heroItem ? heroItem.accountId : "" + readonly property string heroStatus: heroItem ? heroItem.status : "" + readonly property string heroStateKey: heroItem ? heroItem.stateKey : "" + readonly property int heroRow: heroItem ? heroItem.index : -1 + // A stable colour for the hero's stage -- carries a bounded, designed + // fallback (design-plan.md §5/§9) for an offline account that has no + // skin, and no icon of its own the way an instance does, to tint one. + readonly property color heroTint: Format.hashTint(root.heroAccountId) + + ColumnLayout { + anchors.fill: parent + anchors.leftMargin: Theme.space.xl + Theme.space.xs + anchors.rightMargin: Theme.space.xl + Theme.space.xs + anchors.bottomMargin: Theme.space.lg + spacing: Theme.space.lg + + RowLayout { + Layout.fillWidth: true + Layout.maximumWidth: 860 + // Redundant with the empty state's own actions below once there + // is nothing to manage yet. + visible: list.count > 0 + spacing: Theme.space.sm + + Text { + Layout.fillWidth: true + text: qsTr("Games launch with the default account. Sign in with the Microsoft account that owns Minecraft.") + wrapMode: Text.Wrap + color: Theme.palette.textTertiary + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + } + Button { + text: qsTr("Add offline account") + onClicked: offlineDialog.open() + } + Button { + // The hero below already carries its own accent-filled + // "Use this account" when it isn't the default -- both + // showing together broke the one-accent-fill-per-screen + // rule (design-plan.md Principle 1). Adding another account + // is the secondary action once any account already exists. + text: qsTr("Sign in with Microsoft") + icon.source: Icons.url("plus") + onClicked: root.startMicrosoftLogin() + } + } + + // The account games actually launch with, big -- see heroIndex. + Rectangle { + id: hero + Layout.fillWidth: true + Layout.maximumWidth: 860 + visible: !!root.heroItem + implicitHeight: 248 + radius: Theme.radius.xl + // A flat, bordered surface rather than an ornamental accent + // gradient (design-plan.md §5/§8/§9) -- the account's own + // rendered skin (or its designed silhouette fallback) is the + // hero's visual anchor now, not a decorative wash. + color: Theme.palette.surfaceRaised + border.width: 1 + border.color: Theme.palette.border + // Left-aligned content leaves the card's right side flat once + // the ornamental gradient is gone; the same quiet block-grid + // wash the other chrome-only screens use (see AmbientPattern.qml) + // fills that space instead of a second decorative gradient. + // clip: true respects hero's own rounded corners for it. + clip: true + + AmbientPattern { + anchors.fill: parent + tint: root.heroTint + strength: 0.06 + } + + RowLayout { + anchors.fill: parent + anchors.margins: Theme.space.xl + spacing: Theme.space.xl + + // The stage: a floor shadow and either the account's real + // skin or a neutral, per-account-tinted silhouette standing + // on it -- the account's own rendered skin is the visual + // anchor here, not an ornamental glow (design-plan.md §5). + Item { + id: stage + Layout.preferredWidth: 168 + Layout.preferredHeight: 200 + Layout.alignment: Qt.AlignVCenter + + // Soft floor shadow the figure appears to stand on. A + // flattened pill rather than a true ellipse -- Rectangle + // has no radial shape, but a wide, short rounded rect + // reads the same way at this size. + Rectangle { + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + width: 116; height: 20 + radius: height / 2 + color: Format.shade(root.heroTint, Theme.dark ? 0.45 : 0.55, 0.6) + opacity: 0.30 + } + + // Neutral placeholder, shown underneath the body render: + // an offline account (or one whose texture has not + // loaded yet) gets a transparent image back from the + // provider, and this shows through -- same idea as the + // face avatars' initial letter elsewhere on this page, + // tinted per-account (Format.shade) rather than one flat + // neutral grey for every account (design-plan.md §9). + SkinSilhouette { + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + anchors.bottomMargin: 12 + opacity: 0.7 + color: Format.shade(root.heroTint, Theme.dark ? 0.62 : 0.42, 0.5) + } + + Image { + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + anchors.bottomMargin: 12 + width: 88 + height: 176 + fillMode: Image.PreserveAspectFit + smooth: false + source: root.heroAccountId.length > 0 + ? "image://accountface/body/" + root.heroAccountId + "?rev=" + root.imageRevision : "" + sourceSize: Qt.size(176, 352) + } + } + + ColumnLayout { + Layout.fillWidth: true + Layout.minimumWidth: 0 + spacing: Theme.space.sm + + Text { + text: (root.heroIsDefault ? qsTr("ACTIVE ACCOUNT") : qsTr("SUGGESTED DEFAULT")).toUpperCase() + color: Theme.palette.accent + font.family: Theme.font.family + font.pixelSize: Theme.type.overline.pixelSize + font.weight: Font.Bold + font.letterSpacing: Theme.type.overline.letterSpacing * 1.5 + } + + Text { + Layout.fillWidth: true + Layout.minimumWidth: 0 + text: root.heroName + elide: Text.ElideRight + color: Theme.palette.textPrimary + font.family: Theme.font.family + font.pixelSize: Theme.type.display.pixelSize + font.weight: Font.Bold + font.letterSpacing: -0.5 + } + + Row { + spacing: Theme.space.sm + Tag { + iconName: root.heroIsMSA ? "user" : "users" + text: root.heroIsMSA ? qsTr("Microsoft") : qsTr("Offline") + } + StatusBadge { + anchors.verticalCenter: parent.verticalCenter + visible: root.heroIsMSA && root.heroStatus.length > 0 + tone: root.stateTone(root.heroStateKey) + text: root.heroStatus + } + } + + Row { + topPadding: Theme.space.sm + spacing: Theme.space.sm + + Button { + height: Theme.control.heightLg + visible: !root.heroIsDefault + highlighted: true + text: qsTr("Use this account") + onClicked: root.controller.setDefault(root.heroRow) + } + IconButton { + size: Theme.control.heightLg + flat: false + iconName: "image" + tip: root.heroIsMSA ? qsTr("Manage skin & cape") : qsTr("Skins need a Microsoft account") + onClicked: root.heroIsMSA ? root.openSkinEditor(root.heroRow, root.heroAccountId, root.heroName) + : offlineSkinDialog.open() + } + IconButton { + size: Theme.control.heightLg + flat: false + visible: root.heroIsMSA + iconName: "refresh" + tip: qsTr("Sign in again") + onClicked: root.controller.refresh(root.heroRow) + } + IconButton { + size: Theme.control.heightLg + flat: false + iconName: "trash" + tip: qsTr("Remove account") + onClicked: { + removeDialog.row = root.heroRow + removeDialog.text = qsTr("Remove “%1” from MeshMC? You can sign in again at any time.").arg(root.heroName) + removeDialog.open() + } + } + } + } + } + } + + SectionHeader { + Layout.fillWidth: true + Layout.maximumWidth: 860 + visible: list.count > 1 + collapsible: false + title: qsTr("Other accounts") + count: list.count - 1 + } + + ListView { + id: list + Layout.fillWidth: true + Layout.maximumWidth: 860 + Layout.fillHeight: true + clip: true + spacing: Theme.space.sm + boundsBehavior: Flickable.StopAtBounds + model: root.accounts + currentIndex: root.heroIndex + ScrollBar.vertical: ScrollBar {} + + delegate: AccountRow { + width: list.width - Theme.space.md + hidden: index === root.heroIndex + rev: root.imageRevision + onMakeDefaultRequested: root.controller.setDefault(index) + onRefreshRequested: root.controller.refresh(index) + onManageSkinRequested: isMSA ? root.openSkinEditor(index, accountId, shownName) : offlineSkinDialog.open() + onRemoveRequested: { + removeDialog.row = index + removeDialog.text = qsTr("Remove “%1” from MeshMC? You can sign in again at any time.").arg(shownName) + removeDialog.open() + } + } + } + } + + Column { + anchors.centerIn: parent + visible: list.count === 0 + spacing: Theme.space.sm + + EmptyState { + anchors.horizontalCenter: parent.horizontalCenter + title: qsTr("No accounts yet") + body: qsTr("Sign in with the Microsoft account that owns Minecraft to start playing online.") + actionText: qsTr("Sign in with Microsoft") + actionIcon: "user" + onActionTriggered: root.startMicrosoftLogin() + + Item { + width: 96; height: 96 + Rectangle { + anchors.centerIn: parent + width: 96; height: 96 + radius: width / 2 + color: Theme.palette.accent + opacity: 0.10 + } + Rectangle { + anchors.centerIn: parent + width: 72; height: 72 + radius: width / 2 + color: Theme.palette.accentSubtle + } + MeshIcon { anchors.centerIn: parent; iconName: "user"; size: 32; color: Theme.palette.accent } + } + } + + Button { + anchors.horizontalCenter: parent.horizontalCenter + flat: true + text: qsTr("Add offline account") + onClicked: offlineDialog.open() + } + } + + ConfirmDialog { + id: removeDialog + property int row: -1 + title: qsTr("Remove account") + confirmText: qsTr("Remove") + onConfirmed: if (root.controller) root.controller.remove(row) + } + + Dialog { + id: offlineDialog + parent: Overlay.overlay + anchors.centerIn: parent + width: 420 + modal: true + title: qsTr("Add offline account") + onOpened: { + usernameField.text = "" + offlineError.text = "" + usernameField.forceActiveFocus() + } + + contentItem: Column { + spacing: Theme.space.sm + Text { + width: parent.width + text: qsTr("The name shown in game. Offline accounts cannot join online-mode servers.") + wrapMode: Text.Wrap + color: Theme.palette.textSecondary + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + } + TextField { + id: usernameField + width: parent.width + placeholderText: qsTr("Username") + selectByMouse: true + onAccepted: addButton.clicked() + } + Text { + id: offlineError + width: parent.width + visible: text.length > 0 + wrapMode: Text.Wrap + color: Theme.palette.danger + font.family: Theme.font.family + font.pixelSize: Theme.type.caption.pixelSize + } + } + + footer: Row { + layoutDirection: Qt.RightToLeft + spacing: Theme.space.sm + padding: Theme.space.lg + topPadding: 0 + Button { + id: addButton + text: qsTr("Add account") + highlighted: true + enabled: usernameField.text.trim().length > 0 + onClicked: { + if (root.controller && root.controller.addOffline(usernameField.text)) + offlineDialog.close() + else + offlineError.text = qsTr("Offline accounts need a Microsoft account that owns the game first, and a name no other offline account uses.") + } + } + Button { + text: qsTr("Cancel") + flat: true + onClicked: offlineDialog.close() + } + } + } + + Dialog { + id: loginDialog + parent: Overlay.overlay + anchors.centerIn: parent + width: 460 + modal: true + closePolicy: Popup.NoAutoClose + title: qsTr("Sign in with Microsoft") + + readonly property bool done: !!root.login && root.login.succeeded + readonly property bool failed: !!root.login && root.login.failed + + contentItem: Column { + spacing: Theme.space.md + + Row { + spacing: Theme.space.md + BusyIndicator { + anchors.verticalCenter: parent.verticalCenter + visible: !loginDialog.done && !loginDialog.failed + running: visible + width: 32; height: 32 + } + MeshIcon { + anchors.verticalCenter: parent.verticalCenter + visible: loginDialog.done || loginDialog.failed + iconName: loginDialog.done ? "check" : "alert-triangle" + size: Theme.icon.lg + color: loginDialog.done ? Theme.palette.success : Theme.palette.danger + } + Text { + anchors.verticalCenter: parent.verticalCenter + width: 360 + text: loginDialog.done ? qsTr("Signed in. You're ready to play.") + : loginDialog.failed ? (root.login.error || qsTr("Sign-in failed.")) + : (root.login && root.login.status.length > 0 ? root.login.status + : qsTr("Continue in your browser…")) + wrapMode: Text.Wrap + color: Theme.palette.textPrimary + font.family: Theme.font.family + font.pixelSize: Theme.type.body.pixelSize + } + } + + Text { + width: parent.width + visible: !loginDialog.done && !loginDialog.failed + text: qsTr("A browser window opened on Microsoft's sign-in page. Sign in there; this window updates by itself.") + wrapMode: Text.Wrap + color: Theme.palette.textTertiary + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + } + } + + footer: Row { + layoutDirection: Qt.RightToLeft + spacing: Theme.space.sm + padding: Theme.space.lg + topPadding: 0 + Button { + visible: loginDialog.done + highlighted: true + text: qsTr("Done") + onClicked: { loginDialog.close(); root.login = null } + } + Button { + visible: loginDialog.failed + highlighted: true + text: qsTr("Try again") + onClicked: { root.login = root.controller.loginMicrosoft() } + } + Button { + visible: !loginDialog.done && !loginDialog.failed && !!root.login + && root.login.browserUrl.toString().length > 0 + text: qsTr("Open browser again") + icon.source: Icons.url("external-link") + onClicked: root.login.openBrowser() + } + Button { + visible: !loginDialog.done + flat: true + text: qsTr("Cancel") + onClicked: { + if (root.login && root.login.running) + root.login.cancel() + loginDialog.close() + root.login = null + } + } + } + } + + SkinCapeEditor { + id: skinCapeEditor + controller: root.controller + rev: root.imageRevision + } + + Dialog { + id: offlineSkinDialog + parent: Overlay.overlay + anchors.centerIn: parent + width: 380 + modal: true + title: qsTr("Skin & cape") + + contentItem: Text { + width: parent.width + wrapMode: Text.Wrap + text: qsTr("Skins and capes belong to a Microsoft account. Sign in with the Microsoft account that owns Minecraft to change how this character looks.") + color: Theme.palette.textSecondary + font.family: Theme.font.family + font.pixelSize: Theme.type.body.pixelSize + } + + footer: Row { + layoutDirection: Qt.RightToLeft + spacing: Theme.space.sm + padding: Theme.space.lg + topPadding: 0 + Button { + text: qsTr("Close") + onClicked: offlineSkinDialog.close() + } + } + } +} diff --git a/launcher/qml/Components/AmbientPattern.qml b/launcher/qml/Components/AmbientPattern.qml new file mode 100644 index 00000000..8de8c5a7 --- /dev/null +++ b/launcher/qml/Components/AmbientPattern.qml @@ -0,0 +1,103 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls.impl +import MeshMC.Theme + +/* + * A quiet block-grid wash for a chrome-only screen's header region -- one + * with no per-item cover art of its own to bleed behind it the way + * LibraryPage/PlayDock do (see CoverArt.qml). Settings and Discover before + * results would sit on flat canvas otherwise (design-plan.md §5 + * "chrome-only screens"), and the New instance dialog's header. + * Needs a band a few rows tall; a dialog header's short one still works + * once it fades early (fadeStart) and at its sides (fadeSides). + * + * Actually tiles art/ambient/blocks_mask.png -- a dirt/deepslate + * checkerboard from the same generator that draws G3a's block textures, + * pre-converted (generate.py's ambient_tile()) into a luminance alpha mask + * -- rather than an unrelated abstract motif; G3b asked for "a subtle tiled + * block texture (e.g. dirt or deepslate, or a mix)", and a disconnected + * Canvas pattern was a drift from that brief. IconImage recolours it via + * its alpha channel, the exact mechanism MeshIcon.qml already uses for + * every SVG icon in the app (see its own file comment) -- no + * ShaderEffect/MultiEffect, so it works below the Qt 6.5 floor this + * codebase otherwise avoids (see CoverArt.qml/PopupShadow.qml). `tint` + * defaults to the page's own primary text colour, already correct in both + * themes without a second asset; `strength` rides the colour's own alpha, + * so no per-frame drawing cost -- one static, non-animated texture upload, + * matching Theme.motion's contract that a loop only ever runs for a real + * busy/live state (design-plan.md §2.3). + */ +Item { + id: root + + property color tint: Theme.palette.textPrimary + property real strength: Theme.dark ? 0.05 : 0.07 + // For a band that stops short of its surface's bottom (a page's header + // region): the wash fades into `fadeColor` instead of ending on a hard + // line -- the same idiom PageBackdrop.qml's own fadeBottom uses. + property bool fadeBottom: false + // Where, as a fraction of the height, fadeBottom starts to fade. A tall + // page band keeps most of its wash and only thins at the foot; a short + // dialog header needs to start sooner to reach the surface by its edge. + property real fadeStart: 0.7 + // Width, in px, of a fade into fadeColor along the left and right edges + // (0 = none). For a band that sits inside a rounded surface and must not + // end on a hard vertical line. + property int fadeSides: 0 + // What fadeBottom/fadeSides fade into. A page-level caller (Settings, + // Discover) sits directly on the page canvas, so painting an opaque wash + // of this colour over the pattern's edge reads exactly like the pattern + // thinning out into nothing there; a caller on some other surface (a + // dialog's overlay colour) passes that surface's colour instead. + property color fadeColor: Theme.palette.canvas + readonly property int cell: 32 + + IconImage { + anchors.fill: parent + source: PixelArt.ambientMaskUrl() + sourceSize: Qt.size(root.cell, root.cell) + fillMode: Image.Tile + smooth: false + color: Qt.rgba(root.tint.r, root.tint.g, root.tint.b, root.strength) + } + + Rectangle { + visible: root.fadeBottom + anchors.fill: parent + gradient: Gradient { + GradientStop { position: 0.0; color: Qt.rgba(root.fadeColor.r, root.fadeColor.g, root.fadeColor.b, 0) } + GradientStop { position: root.fadeStart; color: Qt.rgba(root.fadeColor.r, root.fadeColor.g, root.fadeColor.b, 0) } + GradientStop { position: 1.0; color: root.fadeColor } + } + } + + Rectangle { + visible: root.fadeSides > 0 + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + width: root.fadeSides + gradient: Gradient { + orientation: Gradient.Horizontal + GradientStop { position: 0.0; color: root.fadeColor } + GradientStop { position: 1.0; color: Qt.rgba(root.fadeColor.r, root.fadeColor.g, root.fadeColor.b, 0) } + } + } + + Rectangle { + visible: root.fadeSides > 0 + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom + width: root.fadeSides + gradient: Gradient { + orientation: Gradient.Horizontal + GradientStop { position: 0.0; color: Qt.rgba(root.fadeColor.r, root.fadeColor.g, root.fadeColor.b, 0) } + GradientStop { position: 1.0; color: root.fadeColor } + } + } +} diff --git a/launcher/qml/Components/BackupsTab.qml b/launcher/qml/Components/BackupsTab.qml new file mode 100644 index 00000000..098f595f --- /dev/null +++ b/launcher/qml/Components/BackupsTab.qml @@ -0,0 +1,279 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Dialogs +import MeshMC.Theme + +/* + * Instance backups (zip snapshots of the whole instance folder) -- the + * widget-free replacement for BackupPage. Create/restore/export/import all + * run off the GUI thread behind a TaskWatcher (see BackupController), so + * this tab shows one progress row for whichever of them is currently in + * flight rather than blocking. + */ +Item { + id: root + + // InstanceDetails.backups (BackupController). + property var controller: null + readonly property var model: root.controller ? root.controller.model : null + readonly property int count: list.count + readonly property bool running: !!root.controller && root.controller.running + + // The TaskWatcher of whichever action is currently running, if any. + property var watcher: null + readonly property bool busy: !!root.watcher && root.watcher.running + // Set when an action call refuses to even start (restoreBackup() + // returning null because the instance is running) - the watcher stays + // null in that case, so this is the only way the tab has to tell the + // user why nothing happened. + property string actionError: "" + + // A different instance's controller: whatever this tab was doing + // belonged to the previous one. + onControllerChanged: { + root.watcher = null + root.actionError = "" + } + + ColumnLayout { + anchors.fill: parent + spacing: Theme.space.md + + RowLayout { + Layout.fillWidth: true + spacing: Theme.space.sm + + Text { + Layout.fillWidth: true + text: root.running + ? qsTr("The game is running; backups can be restored once it has closed.") + : list.count > 0 ? qsTr("%1 backups").arg(list.count) : "" + color: root.running ? Theme.palette.warning : Theme.palette.textTertiary + elide: Text.ElideRight + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + } + Button { + enabled: !root.busy + text: qsTr("Import…") + icon.source: Icons.url("download") + onClicked: importDialog.open() + } + Button { + enabled: !root.busy + text: qsTr("Create backup") + icon.source: Icons.url("archive") + onClicked: labelPrompt.open() + } + } + + RowLayout { + Layout.fillWidth: true + visible: root.busy || root.actionError.length > 0 || (!!root.watcher && root.watcher.failed) + spacing: Theme.space.md + + Text { + Layout.preferredWidth: 140 + text: root.actionError.length > 0 ? qsTr("Restore") : root.watcher ? (root.watcher.title || qsTr("Working…")) : "" + color: root.actionError.length > 0 || (root.watcher && root.watcher.failed) ? Theme.palette.danger : Theme.palette.textSecondary + elide: Text.ElideRight + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + } + Text { + Layout.fillWidth: true + text: root.actionError.length > 0 + ? root.actionError + : root.watcher + ? (root.watcher.failed ? (root.watcher.error || qsTr("Failed.")) + : (root.watcher.status || qsTr("Working…"))) + : "" + color: root.actionError.length > 0 || (root.watcher && root.watcher.failed) ? Theme.palette.danger : Theme.palette.textSecondary + elide: Text.ElideRight + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + } + LaunchProgressBar { + Layout.preferredWidth: 160 + visible: root.busy + progress: root.watcher ? root.watcher.progress : -1 + } + } + + ListView { + id: list + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + spacing: Theme.space.sm + boundsBehavior: Flickable.StopAtBounds + model: root.model + ScrollBar.vertical: ScrollBar {} + + delegate: Rectangle { + id: row + required property int index + required property string name + required property string timestampText + required property string sizeText + + width: list.width - Theme.space.md + height: Theme.control.heightLg + Theme.space.md + radius: Theme.radius.lg + color: Theme.palette.surface + border.width: 1 + border.color: Theme.palette.border + + RowLayout { + anchors.fill: parent + anchors.leftMargin: Theme.space.md + anchors.rightMargin: Theme.space.md + spacing: Theme.space.md + + Rectangle { + Layout.preferredWidth: 36 + Layout.preferredHeight: 36 + radius: Theme.radius.md + color: Theme.palette.surfaceSunken + MeshIcon { anchors.centerIn: parent; iconName: "archive"; size: Theme.icon.sm; color: Theme.palette.textTertiary } + } + + Column { + Layout.fillWidth: true + Layout.minimumWidth: 0 + spacing: 1 + Text { + width: parent.width + text: row.name + elide: Text.ElideRight + color: Theme.palette.textPrimary + font.family: Theme.font.family + font.pixelSize: Theme.type.body.pixelSize + font.weight: Font.Medium + } + Text { + width: parent.width + text: qsTr("%1 · %2").arg(row.timestampText).arg(row.sizeText) + elide: Text.ElideRight + color: Theme.palette.textTertiary + font.family: Theme.font.family + font.pixelSize: Theme.type.caption.pixelSize + } + } + + IconButton { + iconName: "download" + tip: qsTr("Export…") + enabled: !root.busy + onClicked: { + exportDialog.row = row.index + exportDialog.open() + } + } + IconButton { + iconName: "refresh" + tip: root.running ? qsTr("Close the game before restoring a backup.") : qsTr("Restore") + enabled: !root.busy && !root.running + onClicked: { + restoreConfirm.row = row.index + restoreConfirm.text = qsTr("Replace this instance's current contents with the backup “%1”? This cannot be undone.").arg(row.name) + restoreConfirm.open() + } + } + IconButton { + iconName: "trash" + tip: qsTr("Delete") + enabled: !root.busy + onClicked: { + deleteConfirm.row = row.index + deleteConfirm.text = qsTr("Delete the backup “%1”? This cannot be undone.").arg(row.name) + deleteConfirm.open() + } + } + } + } + } + } + + EmptyState { + anchors.centerIn: parent + upperThird: true + visible: list.count === 0 + title: qsTr("No backups yet") + body: qsTr("A backup is a zip of this whole instance, including its worlds and mods, that you can restore later.") + actionText: qsTr("Create backup") + onActionTriggered: labelPrompt.open() + MeshIcon { iconName: "archive"; size: 40; color: Theme.palette.textTertiary } + } + + PromptDialog { + id: labelPrompt + title: qsTr("Create backup") + label: qsTr("Optional label") + placeholder: qsTr("e.g. before updating mods") + confirmText: qsTr("Create") + allowEmpty: true + onSubmitted: (text) => { + if (root.controller) { + root.actionError = "" + root.watcher = root.controller.createBackup(text) + } + close() + } + } + + ConfirmDialog { + id: restoreConfirm + property int row: -1 + title: qsTr("Restore backup") + confirmText: qsTr("Restore") + onConfirmed: { + if (!root.controller) + return + root.actionError = "" + var watcher = root.controller.restoreBackup(row) + if (watcher) + root.watcher = watcher + else + root.actionError = qsTr("Close the game before restoring a backup.") + } + } + + ConfirmDialog { + id: deleteConfirm + property int row: -1 + title: qsTr("Delete backup") + confirmText: qsTr("Delete") + onConfirmed: if (root.controller) { + root.actionError = "" + root.watcher = root.controller.deleteBackup(row) + } + } + + FileDialog { + id: importDialog + title: qsTr("Import backup") + nameFilters: [qsTr("Zip files (*.zip)"), qsTr("All files (*)")] + onAccepted: if (root.controller) { + root.actionError = "" + root.watcher = root.controller.importBackup(selectedFile.toString(), "") + } + } + + FileDialog { + id: exportDialog + property int row: -1 + title: qsTr("Export backup") + fileMode: FileDialog.SaveFile + nameFilters: [qsTr("Zip files (*.zip)")] + onAccepted: if (root.controller) { + root.actionError = "" + root.watcher = root.controller.exportBackup(row, selectedFile.toString()) + } + } +} diff --git a/launcher/qml/Components/BrandMark.qml b/launcher/qml/Components/BrandMark.qml new file mode 100644 index 00000000..1bdbde44 --- /dev/null +++ b/launcher/qml/Components/BrandMark.qml @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick + +/* + * MeshMC's own mark at any square size, rasterised at 2x so it stays crisp + * on HiDPI. Set `size`; the caller positions it. + */ +Image { + property int size: 24 + + width: size + height: size + source: "qrc:/icons/multimc/scalable/instances/meshmc.svg" + sourceSize: Qt.size(size * 2, size * 2) + fillMode: Image.PreserveAspectFit +} diff --git a/launcher/qml/Components/BusyOverlay.qml b/launcher/qml/Components/BusyOverlay.qml new file mode 100644 index 00000000..0039e626 --- /dev/null +++ b/launcher/qml/Components/BusyOverlay.qml @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import MeshMC.Theme + +/* + * Shown while the core runs something the user has to wait for and cannot + * interact around (UiHost::showBusy): a dimmed window, a spinner and what + * is happening. It swallows input, as the modal progress dialog did. + */ +Rectangle { + id: root + + property bool busy: false + property string text + + anchors.fill: parent + z: 900 + color: Theme.palette.scrim + opacity: busy ? 1 : 0 + visible: opacity > 0 + Behavior on opacity { NumberAnimation { duration: Theme.motion.normal } } + + // Eat clicks and wheel so nothing underneath reacts. + MouseArea { + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.AllButtons + onWheel: (wheel) => wheel.accepted = true + } + + Rectangle { + anchors.centerIn: parent + width: Math.min(360, parent.width - Theme.space.xxl * 2) + height: column.implicitHeight + Theme.space.xl * 2 + radius: Theme.radius.xl + color: Theme.palette.surfaceRaised + border.width: 1 + border.color: Theme.palette.border + + Column { + id: column + anchors.centerIn: parent + width: parent.width - Theme.space.xl * 2 + spacing: Theme.space.md + BusyIndicator { + anchors.horizontalCenter: parent.horizontalCenter + running: root.busy + } + Text { + width: parent.width + horizontalAlignment: Text.AlignHCenter + text: root.text.length > 0 ? root.text : qsTr("Working…") + wrapMode: Text.Wrap + color: Theme.palette.textPrimary + font.family: Theme.font.family + font.pixelSize: Theme.type.body.pixelSize + } + } + } +} diff --git a/launcher/qml/Components/CMakeLists.txt b/launcher/qml/Components/CMakeLists.txt new file mode 100644 index 00000000..804a1a8e --- /dev/null +++ b/launcher/qml/Components/CMakeLists.txt @@ -0,0 +1,196 @@ +# SPDX-FileCopyrightText: 2026 Project Tick +# SPDX-FileContributor: Project Tick +# SPDX-License-Identifier: Apache-2.0 + +######## Launcher-specific QML building blocks (MeshMC.Components) ######## + +# Mirrors launcher/qml/CMakeLists.txt: RESOURCE_PREFIX is spelled out by hand +# rather than left to qt_policy(QTP0001) (Qt 6.5+), since the floor here is +# 6.4, and this file lives beside the .qml it lists so the URI (MeshMC.Components) +# and the on-disk path agree. +# +# Not added to the parent CMakeLists here -- the integrator wires this +# library into the MeshMC_qml target once MeshMC.Theme exists to link against. +qt_add_library(MeshMC_qml_components STATIC) + +# Same reasoning as Theme/CMakeLists.txt's Theme.qml: has to run before +# qt_add_qml_module, or Icons.qml registers as an ordinary re-instantiable +# type and every "import MeshMC.Components" gets its own private instance +# instead of the one Icons.url() is meant to be called on. +set_source_files_properties(Icons.qml Format.qml SettingsStore.qml PixelArt.qml PROPERTIES QT_QML_SINGLETON_TYPE TRUE) + +# The landscape fallbacks: 24 scenes at three shapes (card/band/strip, see +# art/scenes.py). Globbed rather than listed line by line -- 72 checked-in +# PNGs -- with CONFIGURE_DEPENDS so regenerating the set is picked up. +file(GLOB MESHMC_ART_LANDSCAPES RELATIVE ${CMAKE_CURRENT_SOURCE_DIR} CONFIGURE_DEPENDS + art/landscapes/card/*.png art/landscapes/band/*.png art/landscapes/strip/*.png) + +qt_add_qml_module(MeshMC_qml_components + URI MeshMC.Components + VERSION 1.0 + RESOURCE_PREFIX "/qt/qml" + QML_FILES + CoverArt.qml + InstanceCard.qml + InstanceListRow.qml + InstanceSection.qml + LibraryPage.qml + ContinueCard.qml + PlayButton.qml + LaunchProgressBar.qml + IconButton.qml + Tag.qml + SearchBox.qml + RecentItem.qml + Format.qml + SettingsPage.qml + DiscoverPage.qml + ModpackCard.qml + ModpackDetail.qml + Skeleton.qml + SettingsScroll.qml + SettingsGroup.qml + SettingsStore.qml + SettingsSource.qml + OverrideGroup.qml + InstanceSettingsTab.qml + InstanceOverviewTab.qml + InstancePage.qml + ContentTab.qml + VersionTab.qml + LoaderInstallDialog.qml + MinecraftVersionDialog.qml + GameOptionsTab.qml + PackList.qml + PalettePicker.qml + ContentBrowserView.qml + PluginNode.qml + PluginSurfaces.qml + UiRequestDialog.qml + BusyOverlay.qml + OnboardingView.qml + WorldsTab.qml + ScreenshotsTab.qml + LogTab.qml + TabStrip.qml + StatTile.qml + ConfirmDialog.qml + DialogHeader.qml + AccountsPage.qml + AccountRow.qml + SkinCapeEditor.qml + CapeTile.qml + NewInstanceDialog.qml + PromptDialog.qml + Toast.qml + IconPickerDialog.qml + SettingRow.qml + SettingSwitch.qml + SettingChoice.qml + SettingNumber.qml + SettingText.qml + SettingPathField.qml + SettingsProxySection.qml + SettingsExternalToolsSection.qml + SettingsLogUploadSection.qml + MemorySetting.qml + SegmentedControl.qml + EmptyState.qml + SidebarNav.qml + NavItem.qml + NavSelectionIndicator.qml + AccountChip.qml + ProfileButton.qml + ProfileMenu.qml + PlayDock.qml + TopBar.qml + SectionHeader.qml + StatusBadge.qml + Gallery.qml + MeshIcon.qml + Icons.qml + PixelArt.qml + HomePage.qml + HomeJumpCard.qml + HomeWorldTile.qml + AmbientPattern.qml + PageBackdrop.qml + BrandMark.qml + SkinSilhouette.qml + ServersTab.qml + BackupsTab.qml + DataPacksTab.qml + ManagedPackTab.qml + RESOURCES + icons/alert-triangle.svg + icons/archive.svg + icons/arrow-down.svg + icons/arrow-up.svg + icons/bell.svg + icons/check.svg + icons/chevron-down.svg + icons/chevron-left.svg + icons/chevron-right.svg + icons/clock.svg + icons/compass.svg + icons/copy.svg + icons/cube.svg + icons/download.svg + icons/edit.svg + icons/external-link.svg + icons/folder.svg + icons/globe.svg + icons/grid.svg + icons/home.svg + icons/image.svg + icons/info.svg + icons/layers.svg + icons/library.svg + icons/list.svg + icons/log-out.svg + icons/more.svg + icons/moon.svg + icons/package.svg + icons/play.svg + icons/plus.svg + icons/refresh.svg + icons/search.svg + icons/server.svg + icons/settings.svg + icons/sort.svg + icons/stop.svg + icons/sun.svg + icons/terminal.svg + icons/trash.svg + icons/user.svg + icons/users.svg + icons/x.svg + # Original pixel art (see art/generate.py) -- checked-in PNGs, never + # generated at build or run time. + art/blocks/cobblestone.png + art/blocks/deepslate.png + art/blocks/dirt.png + art/blocks/grass_side.png + art/blocks/grass_top.png + art/blocks/gravel.png + art/blocks/oak_planks.png + art/blocks/sand.png + art/blocks/stone.png + art/ambient/blocks_mask.png + ${MESHMC_ART_LANDSCAPES} + art/hero/home_hero.png + art/empty/no_instances.png + art/empty/no_worlds.png + art/empty/not_found.png + IMPORTS + MeshMC.Theme +) + +# MeshIcon.qml reaches into QtQuick.Controls.impl (IconImage) directly, same +# as the Style module's use of the same import (see Style/CMakeLists.txt) -- +# Quick + QuickControls2 already resolve it at runtime, so no separate +# Qt::QuickControls2Impl link is needed. +target_link_libraries(MeshMC_qml_components PUBLIC + Qt${QT_VERSION_MAJOR}::Quick + Qt${QT_VERSION_MAJOR}::QuickControls2 +) diff --git a/launcher/qml/Components/CapeTile.qml b/launcher/qml/Components/CapeTile.qml new file mode 100644 index 00000000..72a513d6 --- /dev/null +++ b/launcher/qml/Components/CapeTile.qml @@ -0,0 +1,135 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import MeshMC.Theme + +/* + * One choice in the skin/cape editor's cape picker: either a real cape, the + * "No cape" choice (which has no texture, by definition -- an "x"), or a + * cape the account owns whose texture has not resolved yet (a neutral cape + * silhouette, never the same "x" as "No cape": the account still owns that + * cape, it just cannot be drawn yet). + * + * A cape texture is 64x32, with its front-facing strip -- the part worth + * showing in a small tile -- an 10x16 region at (1, 1) (the same region + * SkinManageDialog's own thumbnail crops, see kCapeFrontRegion in + * ui/dialogs/skins/SkinManageDialog.cpp). QML's Image has no source + * rectangle of its own, so the crop is done by overscaling the whole + * texture inside a clipped Item instead: the image is drawn at a size and + * offset that puts just that region inside the clip, and clip: true throws + * the rest away. + */ +Item { + id: root + + property string label + property string capeUrl: "" + property bool selected: false + // Set only by the picker's dedicated "No cape" tile -- see + // SkinCapeEditor.qml. Every other tile is a cape the account owns, + // whether or not its texture has resolved yet. + property bool isNoCapeOption: false + signal clicked() + + readonly property bool hasCape: root.capeUrl.length > 0 + + implicitWidth: 72 + implicitHeight: 100 + + Rectangle { + id: card + anchors.fill: parent + radius: Theme.radius.md + color: root.selected ? Theme.palette.accentSubtle : Theme.palette.surface + border.width: root.selected ? 2 : 1 + border.color: root.selected ? Theme.palette.accent : Theme.palette.border + + Behavior on color { ColorAnimation { duration: Theme.motion.fast; easing.type: Theme.motion.easing } } + Behavior on border.color { ColorAnimation { duration: Theme.motion.fast; easing.type: Theme.motion.easing } } + } + + Item { + id: thumb + anchors.top: parent.top + anchors.topMargin: Theme.space.xs + anchors.horizontalCenter: parent.horizontalCenter + width: 40 + height: 64 + clip: true + visible: root.hasCape + + // Scale the whole 64x32 texture up so its 10x16 front strip at + // (1, 1) fills this item, then let clip: true crop away the rest. + readonly property real cropScale: width / 10 + + Image { + source: root.capeUrl + asynchronous: true + smooth: false + width: 64 * thumb.cropScale + height: 32 * thumb.cropScale + x: -1 * thumb.cropScale + y: -1 * thumb.cropScale + } + } + + MeshIcon { + anchors.centerIn: thumb + visible: !root.hasCape && root.isNoCapeOption + iconName: "x" + size: Theme.icon.md + color: Theme.palette.textTertiary + } + + // A generic cloak shape for a cape the account owns but whose texture is + // not known yet -- there is no such icon in icons/, and unlike "No + // cape" this is not an absence, so it must not read as one. + Canvas { + id: capeSilhouette + anchors.centerIn: thumb + width: 34; height: 46 + visible: !root.hasCape && !root.isNoCapeOption + onPaint: { + const ctx = getContext("2d") + ctx.reset() + ctx.fillStyle = Theme.palette.textTertiary + ctx.beginPath() + ctx.moveTo(width * 0.5, 0) + ctx.lineTo(width * 0.78, height * 0.16) + ctx.lineTo(width * 0.94, height) + ctx.quadraticCurveTo(width * 0.5, height * 0.86, width * 0.06, height) + ctx.lineTo(width * 0.22, height * 0.16) + ctx.closePath() + ctx.fill() + } + onVisibleChanged: if (visible) requestPaint() + Component.onCompleted: requestPaint() + // Theme.dark isn't a per-instance signal, but Theme itself is a + // singleton every instance shares, so this Connections is enough + // to repaint every tile on a scheme/mode switch. + Connections { + target: Theme + function onPaletteChanged() { capeSilhouette.requestPaint() } + } + } + + Text { + anchors.top: thumb.bottom + anchors.topMargin: Theme.space.xs + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: Theme.space.xxs + horizontalAlignment: Text.AlignHCenter + text: root.label + elide: Text.ElideRight + color: Theme.palette.textSecondary + font.family: Theme.font.family + font.pixelSize: Theme.type.caption.pixelSize + } + + TapHandler { + onTapped: root.clicked() + } +} diff --git a/launcher/qml/Components/ConfirmDialog.qml b/launcher/qml/Components/ConfirmDialog.qml new file mode 100644 index 00000000..c52a4157 --- /dev/null +++ b/launcher/qml/Components/ConfirmDialog.qml @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import MeshMC.Theme + +/* + * "Are you sure?" for anything that cannot be undone from inside the + * launcher. The confirming button names the action ("Delete world"), never + * a bare OK, and Escape or a click outside cancels. + */ +Dialog { + id: root + + property string text + property string confirmText: qsTr("Delete") + // Every confirm dialog in this codebase guards a destructive action + // (delete, remove); a caller that ever needs the plain accent-coloured + // button back can set this to false. + property bool danger: true + signal confirmed() + + parent: Overlay.overlay + anchors.centerIn: parent + width: Math.min(440, parent ? parent.width - Theme.space.xxl * 2 : 440) + modal: true + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + + header: DialogHeader { + title: root.title + icon: root.danger ? "alert-triangle" : "" + iconColor: Theme.palette.danger + } + + contentItem: Text { + text: root.text + wrapMode: Text.Wrap + color: Theme.palette.textSecondary + font.family: Theme.font.family + font.pixelSize: Theme.type.body.pixelSize + lineHeight: 1.3 + } + + footer: Row { + layoutDirection: Qt.RightToLeft + spacing: Theme.space.sm + padding: Theme.space.lg + topPadding: 0 + + Button { + text: root.confirmText + highlighted: true + danger: root.danger + onClicked: { + root.close() + root.confirmed() + } + } + Button { + text: qsTr("Cancel") + flat: true + onClicked: root.close() + } + } +} diff --git a/launcher/qml/Components/ContentBrowserView.qml b/launcher/qml/Components/ContentBrowserView.qml new file mode 100644 index 00000000..3bb00971 --- /dev/null +++ b/launcher/qml/Components/ContentBrowserView.qml @@ -0,0 +1,553 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import MeshMC.Theme + +/* + * Find mods, resource packs, shaders and data packs for this instance on + * Modrinth or CurseForge, and install them -- dependencies included -- + * without leaving the instance. Results already match the instance's + * Minecraft version and loader; things already installed say so. + * + * Results on the left, the picked project on the right with its versions, + * the newest compatible one preselected. Installs run side by side: each + * project keeps its own progress, shown on its row, so browsing on and + * installing something else never waits for the previous download. + */ +Item { + id: root + + // ContentBrowser of the open instance. + property var browser: null + // (row, versionId) -> TaskWatcher, C++-owned. + property var installer: null + property int selectedRow: -1 + property var selectedProject: null + property int pickedVersion: -1 + + readonly property var versions: browser && browser.versions ? browser.versions : [] + + // Every install started from here, by project key. A plain JS object + // does not notify, so `downloadsRevision` is bumped on every change and + // read by whatever looks a download up. + property var downloads: ({}) + property int downloadsRevision: 0 + readonly property int activeDownloads: { + root.downloadsRevision + var n = 0 + for (var key in root.downloads) { + if (root.downloads[key] && root.downloads[key].running) + ++n + } + return n + } + + function projectKey(title, author) { + var provider = root.browser ? root.browser.provider : "" + var type = root.browser ? root.browser.contentType : "" + return provider + "/" + type + "/" + title + "/" + author + } + function downloadFor(key) { + root.downloadsRevision + return root.downloads[key] || null + } + readonly property var selectedDownload: selectedProject + ? downloadFor(projectKey(selectedProject.title, selectedProject.author)) : null + readonly property bool selectedInstalling: !!selectedDownload && selectedDownload.running + + function install() { + if (!root.installer || root.pickedVersion < 0 || !root.selectedProject) + return + var watcher = root.installer(root.selectedRow, root.versions[root.pickedVersion].id) + if (!watcher) + return + root.downloads[projectKey(root.selectedProject.title, root.selectedProject.author)] = watcher + watcher.runningChanged.connect(function () { root.downloadsRevision++ }) + root.downloadsRevision++ + } + + readonly property var providers: [ + { value: "modrinth", label: "Modrinth" }, + { value: "curseforge", label: "CurseForge" } + ] + readonly property var types: [ + { value: "mods", label: qsTr("Mods") }, + { value: "resourcepacks", label: qsTr("Resource packs") }, + { value: "shaderpacks", label: qsTr("Shaders") }, + { value: "datapacks", label: qsTr("Data packs") } + ] + + property bool searched: false + function runSearch() { + if (!root.browser) + return + root.searched = true + root.selectedRow = -1 + root.selectedProject = null + root.browser.search() + } + function searchIfFirstShown() { + if (visible && !root.searched) + runSearch() + } + onVisibleChanged: searchIfFirstShown() + onBrowserChanged: { root.searched = false; searchIfFirstShown() } + + function select(row, project) { + root.selectedRow = row + root.selectedProject = project + root.pickedVersion = -1 + descriptionText.expanded = false + root.browser.loadVersions(row) + } + + onVersionsChanged: { + var compatible = root.versions.findIndex(v => v.isCompatible) + root.pickedVersion = compatible >= 0 ? compatible : (root.versions.length > 0 ? 0 : -1) + } + + Timer { + id: debounce + interval: 350 + onTriggered: { + root.browser.query = searchField.text + root.runSearch() + } + } + + ColumnLayout { + anchors.fill: parent + spacing: Theme.space.md + + // What to look for, and where. + RowLayout { + Layout.fillWidth: true + spacing: Theme.space.md + + SegmentedControl { + options: root.types + current: root.browser ? root.browser.contentType : "mods" + onActivated: (value) => { root.browser.contentType = value; root.runSearch() } + } + Item { Layout.fillWidth: true } + // Installs in flight, so a download started on another project + // is still visible after moving on. + Rectangle { + visible: root.activeDownloads > 0 + implicitWidth: downloadsRow.implicitWidth + Theme.space.md * 2 + implicitHeight: Theme.control.heightSm + radius: Theme.radius.pill + color: Theme.palette.accentSubtle + Row { + id: downloadsRow + anchors.centerIn: parent + spacing: Theme.space.xs + BusyIndicator { + anchors.verticalCenter: parent.verticalCenter + width: Theme.icon.sm + height: Theme.icon.sm + running: parent.visible + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: qsTr("%n installing", "", root.activeDownloads) + color: Theme.palette.accentText + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + font.weight: Font.DemiBold + } + } + } + SegmentedControl { + options: root.providers + current: root.browser ? root.browser.provider : "modrinth" + onActivated: (value) => { root.browser.provider = value; root.runSearch() } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: Theme.space.md + + SearchBox { + id: searchField + Layout.fillWidth: true + placeholderText: qsTr("Search") + onTextEdited: debounce.restart() + onTextChanged: if (text.length === 0) debounce.restart() + } + ComboBox { + Layout.preferredWidth: 200 + visible: count > 0 + model: root.browser && root.browser.sortOptions ? root.browser.sortOptions : [] + textRole: "label" + valueRole: "id" + onActivated: { root.browser.sortIndex = currentValue; root.runSearch() } + } + } + + RowLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: Theme.space.lg + + // Results + ListView { + id: results + Layout.fillWidth: true + Layout.fillHeight: true + Layout.preferredWidth: 3 + Layout.minimumWidth: 0 + clip: true + spacing: Theme.space.xs + boundsBehavior: Flickable.StopAtBounds + model: root.browser ? root.browser.results : null + ScrollBar.vertical: ScrollBar {} + onAtYEndChanged: if (atYEnd && root.browser && root.browser.canFetchMore && !root.browser.searching) + root.browser.fetchMore() + + delegate: AbstractButton { + id: row + required property int index + required property string title + required property string description + required property string author + required property string logoUrl + required property bool installed + readonly property bool selected: root.selectedRow === index + readonly property var download: root.downloadFor(root.projectKey(title, author)) + readonly property bool downloading: !!download && download.running + readonly property bool justInstalled: !!download && download.succeeded + + width: results.width - Theme.space.md + height: 60 + hoverEnabled: true + onClicked: root.select(index, { title: title, author: author, description: description, logoUrl: logoUrl, installed: installed }) + + background: Rectangle { + radius: Theme.radius.md + color: row.selected ? Theme.palette.accentSubtle + : row.hovered ? Theme.palette.surfaceRaised : Theme.palette.surface + border.width: 1 + border.color: row.selected ? Theme.palette.accent : Theme.palette.border + Behavior on color { ColorAnimation { duration: Theme.motion.fast } } + + // The download's progress along the row's foot. + LaunchProgressBar { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: Theme.space.xs + height: 3 + visible: row.downloading + progress: row.download ? row.download.progress : -1 + } + } + + contentItem: RowLayout { + spacing: Theme.space.md + Rectangle { + Layout.leftMargin: Theme.space.sm + Layout.preferredWidth: 40 + Layout.preferredHeight: 40 + radius: Theme.radius.md + color: Theme.palette.surfaceSunken + clip: true + Image { + anchors.fill: parent + anchors.margins: 1 + source: row.logoUrl + sourceSize: Qt.size(80, 80) + fillMode: Image.PreserveAspectCrop + asynchronous: true + } + } + Column { + Layout.fillWidth: true + Layout.minimumWidth: 0 + spacing: 2 + Text { + width: parent.width + text: row.title + elide: Text.ElideRight + color: Theme.palette.textPrimary + font.family: Theme.font.family + font.pixelSize: Theme.type.body.pixelSize + font.weight: Font.DemiBold + } + Text { + width: parent.width + text: row.downloading ? (row.download.status || qsTr("Installing…")) : row.description + elide: Text.ElideRight + color: row.downloading ? Theme.palette.accentText : Theme.palette.textTertiary + font.family: Theme.font.family + font.pixelSize: Theme.type.caption.pixelSize + } + } + StatusBadge { + Layout.rightMargin: Theme.space.sm + visible: row.installed || row.justInstalled || (!!row.download && row.download.failed) + tone: row.download && row.download.failed ? "danger" : "success" + text: row.download && row.download.failed ? qsTr("Failed") : qsTr("Installed") + } + } + } + + footer: Item { + width: results.width + height: root.browser && root.browser.searching ? 64 : Theme.space.md + BusyIndicator { + anchors.centerIn: parent + running: !!root.browser && root.browser.searching + visible: running + } + } + + EmptyState { + anchors.centerIn: parent + visible: root.searched && !!root.browser && !root.browser.searching && results.count === 0 + title: root.browser && root.browser.error.length > 0 ? qsTr("Search failed") : qsTr("Nothing found") + body: root.browser && root.browser.error.length > 0 ? root.browser.error + : qsTr("Try other words, or the other platform.") + MeshIcon { iconName: "search"; size: 40; color: Theme.palette.textTertiary } + } + } + + // Selected project: a compact header, then the versions, which + // get every pixel the header does not need. + Rectangle { + Layout.fillHeight: true + Layout.fillWidth: true + Layout.preferredWidth: 2 + Layout.minimumWidth: 300 + Layout.maximumWidth: 440 + radius: Theme.radius.lg + color: Theme.palette.surface + border.width: 1 + border.color: Theme.palette.border + + ColumnLayout { + anchors.fill: parent + anchors.margins: Theme.space.lg + spacing: Theme.space.md + visible: !!root.selectedProject + + RowLayout { + Layout.fillWidth: true + spacing: Theme.space.md + Rectangle { + Layout.preferredWidth: 48 + Layout.preferredHeight: 48 + radius: Theme.radius.md + color: Theme.palette.surfaceSunken + Image { + anchors.fill: parent + anchors.margins: 1 + source: root.selectedProject ? root.selectedProject.logoUrl : "" + sourceSize: Qt.size(96, 96) + fillMode: Image.PreserveAspectCrop + asynchronous: true + } + } + Column { + Layout.fillWidth: true + Layout.minimumWidth: 0 + spacing: 2 + Text { + width: parent.width + text: root.selectedProject ? root.selectedProject.title : "" + elide: Text.ElideRight + color: Theme.palette.textPrimary + font.family: Theme.font.family + font.pixelSize: Theme.type.title.pixelSize + 2 + font.weight: Font.Bold + } + Text { + width: parent.width + visible: text.length > 0 + text: root.selectedProject && root.selectedProject.author ? qsTr("by %1").arg(root.selectedProject.author) : "" + elide: Text.ElideRight + color: Theme.palette.textTertiary + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + } + } + } + + // Three lines, the rest on request: the version list is + // what this panel is for. + Text { + id: descriptionText + property bool expanded: false + Layout.fillWidth: true + visible: text.length > 0 + text: root.selectedProject ? root.selectedProject.description : "" + wrapMode: Text.Wrap + maximumLineCount: expanded ? 12 : 3 + elide: Text.ElideRight + color: Theme.palette.textSecondary + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + lineHeight: 1.25 + MouseArea { + anchors.fill: parent + enabled: descriptionText.truncated || descriptionText.expanded + cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: descriptionText.expanded = !descriptionText.expanded + } + } + + RowLayout { + Layout.fillWidth: true + Text { + Layout.fillWidth: true + text: qsTr("Versions") + color: Theme.palette.textPrimary + font.family: Theme.font.family + font.pixelSize: Theme.type.bodyStrong.pixelSize + font.weight: Font.Bold + } + Text { + visible: root.versions.length > 0 + text: qsTr("%n available", "", root.versions.length) + color: Theme.palette.textTertiary + font.family: Theme.font.family + font.pixelSize: Theme.type.caption.pixelSize + } + } + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + Layout.minimumHeight: 160 + radius: Theme.radius.md + color: Theme.palette.surfaceSunken + border.width: 1 + border.color: Theme.palette.border + + ListView { + id: versionList + anchors.fill: parent + anchors.margins: Theme.space.xs + clip: true + spacing: 2 + model: root.versions + boundsBehavior: Flickable.StopAtBounds + ScrollBar.vertical: ScrollBar {} + + delegate: AbstractButton { + id: versionRow + required property int index + required property var modelData + readonly property bool picked: root.pickedVersion === index + + width: versionList.width - Theme.space.sm + height: 40 + hoverEnabled: true + onClicked: root.pickedVersion = index + + background: Rectangle { + radius: Theme.radius.sm + 2 + color: versionRow.picked ? Theme.palette.accentSubtle + : versionRow.hovered ? Theme.palette.hoverOverlay : "transparent" + border.width: versionRow.picked ? 1 : 0 + border.color: Theme.palette.accent + } + contentItem: RowLayout { + spacing: Theme.space.sm + Column { + Layout.fillWidth: true + Layout.minimumWidth: 0 + Layout.leftMargin: Theme.space.sm + spacing: 0 + Text { + width: parent.width + text: versionRow.modelData.versionNumber || versionRow.modelData.name + elide: Text.ElideRight + color: versionRow.modelData.isCompatible ? Theme.palette.textPrimary : Theme.palette.textTertiary + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + font.weight: versionRow.picked ? Font.Bold : Font.Medium + } + Text { + width: parent.width + text: [ (versionRow.modelData.gameVersions || []).slice(0, 3).join(", "), + (versionRow.modelData.loaders || []).join(", "), + versionRow.modelData.isCompatible ? "" : qsTr("not for this instance") ] + .filter(t => t.length > 0).join(" · ") + elide: Text.ElideRight + color: versionRow.modelData.isCompatible ? Theme.palette.textTertiary : Theme.palette.warning + font.family: Theme.font.family + font.pixelSize: Theme.type.caption.pixelSize + } + } + MeshIcon { + Layout.rightMargin: Theme.space.sm + visible: versionRow.picked + iconName: "check" + size: Theme.icon.sm + color: Theme.palette.accentText + } + } + } + + BusyIndicator { + anchors.centerIn: parent + running: !!root.browser && root.browser.versionsLoading + visible: running + } + } + } + + Text { + Layout.fillWidth: true + visible: text.length > 0 + readonly property var d: root.selectedDownload + text: d && d.failed ? (d.error || qsTr("Install failed.")) + : d && d.succeeded ? qsTr("Installed.") + : d ? (d.status || "") : "" + wrapMode: Text.Wrap + maximumLineCount: 2 + elide: Text.ElideRight + color: d && d.failed ? Theme.palette.danger + : d && d.succeeded ? Theme.palette.success : Theme.palette.textSecondary + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + } + LaunchProgressBar { + Layout.fillWidth: true + visible: root.selectedInstalling + progress: root.selectedDownload ? root.selectedDownload.progress : -1 + } + + Button { + Layout.fillWidth: true + highlighted: true + enabled: !root.selectedInstalling && root.pickedVersion >= 0 && !!root.installer + text: root.selectedInstalling ? qsTr("Installing…") + : root.selectedProject && root.selectedProject.installed ? qsTr("Install this version") + : qsTr("Install") + icon.source: Icons.url("download") + onClicked: root.install() + } + } + + Text { + anchors.centerIn: parent + width: parent.width - Theme.space.xxl * 2 + horizontalAlignment: Text.AlignHCenter + visible: !root.selectedProject + text: qsTr("Pick something on the left to see its versions and install it. Required dependencies come along automatically.") + wrapMode: Text.Wrap + color: Theme.palette.textTertiary + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + } + } + } + } +} diff --git a/launcher/qml/Components/ContentTab.qml b/launcher/qml/Components/ContentTab.qml new file mode 100644 index 00000000..e90feb82 --- /dev/null +++ b/launcher/qml/Components/ContentTab.qml @@ -0,0 +1,150 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Dialogs +import MeshMC.Theme + +/* + * The instance's installed content: mods, resource packs, shader packs and + * -- on old enough instances -- texture packs, one PackList reused across + * all four kinds. Locked while the game runs, same as the old Mods tab was: + * the files are open, and a change would only apply to the next launch. + */ +Item { + id: root + + // InstanceDetails: mods/resourcePacks/shaderPacks/texturePacks (+ *Dir), + // isMinecraft, contentChangesAllowed, setEnabled, remove, install. + property var details: null + property string kind: "mods" + readonly property bool unlocked: !!details && details.contentChangesAllowed + readonly property int count: packList.count + signal openFolderRequested(string path) + signal browseRequested() + + readonly property bool isMinecraft: !!details && details.isMinecraft + + // One entry per content kind this instance actually has; texture packs + // only show up on instances old enough to use them (InstanceDetails + // already hides that model behind a null texturePacks otherwise). + readonly property var kinds: { + var list = [ + { value: "mods", label: qsTr("Mods"), icon: "package", + model: root.details ? root.details.mods : null, + dir: root.details ? root.details.modsDir : "", + emptyTitle: qsTr("No mods"), + emptyBody: qsTr("Drop .jar files into the mods folder, add one from a file, or install a modpack from Discover.") }, + { value: "resourcepacks", label: qsTr("Resource packs"), icon: "image", + model: root.details ? root.details.resourcePacks : null, + dir: root.details ? root.details.resourcePacksDir : "", + emptyTitle: qsTr("No resource packs"), + emptyBody: qsTr("Change how the game looks -- add one from a file, or browse for one.") }, + { value: "shaderpacks", label: qsTr("Shader packs"), icon: "layers", + model: root.details ? root.details.shaderPacks : null, + dir: root.details ? root.details.shaderPacksDir : "", + emptyTitle: qsTr("No shader packs"), + emptyBody: qsTr("Needs a shader-capable renderer, such as Iris or OptiFine, to have any effect.") } + ] + if (root.details && root.details.texturePacks) { + list.push({ value: "texturepacks", label: qsTr("Texture packs"), icon: "grid", + model: root.details.texturePacks, + dir: root.details.texturePacksDir, + emptyTitle: qsTr("No texture packs"), + emptyBody: qsTr("This version predates resource packs and uses texture packs instead.") }) + } + return list + } + + readonly property var current: { + for (var i = 0; i < kinds.length; ++i) { + if (kinds[i].value === root.kind) + return kinds[i] + } + return kinds.length > 0 ? kinds[0] : null + } + + ColumnLayout { + anchors.fill: parent + spacing: Theme.space.md + visible: root.isMinecraft + + RowLayout { + Layout.fillWidth: true + spacing: Theme.space.md + + SegmentedControl { + options: root.kinds.map((k) => ({ value: k.value, label: k.label })) + current: root.kind + onActivated: (value) => root.kind = value + } + + Text { + Layout.fillWidth: true + text: root.unlocked ? "" : qsTr("The game is running; content can be changed once it has closed.") + color: Theme.palette.warning + elide: Text.ElideRight + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + } + + Button { + flat: true + text: qsTr("Browse online") + onClicked: root.browseRequested() + } + Button { + text: qsTr("Open folder") + icon.source: Icons.url("folder") + onClicked: root.openFolderRequested(root.current ? root.current.dir : "") + } + Button { + highlighted: true + enabled: root.unlocked + text: qsTr("Add from file…") + icon.source: Icons.url("plus") + onClicked: fileDialog.open() + } + } + + PackList { + id: packList + Layout.fillWidth: true + Layout.fillHeight: true + details: root.details + kind: root.kind + model: root.current ? root.current.model : null + unlocked: root.unlocked + iconName: root.current ? root.current.icon : "package" + } + } + + EmptyState { + anchors.centerIn: parent + visible: root.isMinecraft && packList.count === 0 + title: root.current ? root.current.emptyTitle : "" + body: root.current ? root.current.emptyBody : "" + actionText: qsTr("Add from file…") + onActionTriggered: fileDialog.open() + MeshIcon { iconName: root.current ? root.current.icon : "package"; size: 40; color: Theme.palette.textTertiary } + } + + EmptyState { + anchors.centerIn: parent + visible: !root.isMinecraft + title: qsTr("No content") + body: qsTr("This instance cannot have mods, resource packs or shader packs.") + MeshIcon { iconName: "package"; size: 40; color: Theme.palette.textTertiary } + } + + FileDialog { + id: fileDialog + title: root.current ? qsTr("Add %1").arg(root.current.label.toLowerCase()) : qsTr("Add content") + nameFilters: root.kind === "mods" ? [qsTr("Mod files (*.jar *.zip *.litemod)"), qsTr("All files (*)")] + : [qsTr("Pack files (*.zip)"), qsTr("All files (*)")] + onAccepted: if (root.details) root.details.install(root.kind, selectedFile.toString()) + } +} diff --git a/launcher/qml/Components/ContinueCard.qml b/launcher/qml/Components/ContinueCard.qml new file mode 100644 index 00000000..ef246cbd --- /dev/null +++ b/launcher/qml/Components/ContinueCard.qml @@ -0,0 +1,293 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import MeshMC.Theme + +/* + * The hero at the top of the library: one instance, big, with Play as the + * obvious next step. Which instance that is -- the selected one, the one + * played last, or simply the first -- is the page's decision; `overline` + * says which, so the user is never left guessing why this one is shown. + */ +Rectangle { + id: root + + required property string instanceId + required property string name + required property string iconKey + required property bool isRunning + required property bool canLaunch + required property var lastLaunch + required property var totalTimePlayed + required property string gameVersion + required property string loader + required property color iconTint + // Newest screenshot url (InstanceList's coverImage role), or "" -- the + // hero's own full-bleed backdrop, same source as InstanceCard's cover. + required property string coverImage + required property string launchStatus + required property real launchProgress + readonly property bool launching: launchStatus.length > 0 + readonly property bool hasPhoto: coverImage.length > 0 + + property string overline: qsTr("Continue playing") + property string editText: qsTr("Edit") + // A shorter banner for pages where the content below matters more. + property bool compact: false + // False on the instance page: the persistent play bar plays and stops + // the opened instance now, so its own header no longer needs a second + // Play/Stop control. + property bool showPlay: true + // False on the instance page: its own tabs (Servers, Backups, Worlds, + // Managed pack, ...) now cover everything the widget "Classic editor" + // opened, so that button is gone there -- see InstancePage.qml. + property bool showEdit: true + // False on the instance page: nothing under the QML shell answers the + // "more" menu yet (see InstancePage.qml), and a button that opens + // nothing is worse than no button. + property bool showMenu: true + // A one-line strip: title and tags beside the icon, actions on the + // right -- for tabs whose content needs the height (the instance page + // outside its Overview). + property bool slim: false + + signal playRequested() + signal stopRequested() + signal editRequested() + signal folderRequested() + signal menuRequested() + + implicitHeight: slim ? content.implicitHeight + Theme.space.lg * 2 + : Math.max(compact ? 184 : 240, content.implicitHeight + (compact ? Theme.space.xl : Theme.space.xxl) * 2) + Behavior on implicitHeight { NumberAnimation { duration: Theme.motion.normal; easing.type: Theme.motion.easing } } + radius: Theme.radius.xl + border.width: 1 + border.color: Theme.palette.border + // Hidden behind the full-bleed CoverArt below, which also covers this + // rectangle's own border -- see `outline` for the one that shows. + color: Theme.palette.surface + // CoverArt's hover zoom would otherwise poke past the rounded corner. + clip: true + + CoverArt { + id: art + anchors.fill: parent + radius: root.radius + // The hero sits straight on the page. + matte: Theme.palette.canvas + source: root.coverImage + tint: root.iconTint + // The tile to the right already carries the icon; showing it again, + // huge, in the backdrop would just be clutter. + iconKey: "" + scrim: "horizontal" + } + + Row { + id: content + anchors.left: parent.left + anchors.leftMargin: root.slim ? Theme.space.lg : Theme.space.xxl + anchors.right: parent.right + anchors.rightMargin: root.slim ? slimActions.width + Theme.space.lg * 2 : Theme.space.xxl + anchors.verticalCenter: parent.verticalCenter + spacing: root.slim ? Theme.space.md : Theme.space.xl + + Item { + id: tile + anchors.verticalCenter: parent.verticalCenter + width: root.slim ? 56 : root.compact ? 96 : 124 + height: width + + // A soft shadow: a larger, blurred-by-opacity copy of the tile + // offset below it -- the layered-rectangle trick this codebase + // uses in place of a drop-shadow effect. Needed now that the + // tile can sit over a photo instead of always the flat surface + // colour, so it still reads as raised. + Rectangle { + anchors.fill: parent + anchors.margins: -3 + anchors.topMargin: 1 + radius: Theme.radius.xl + 3 + color: Qt.rgba(0, 0, 0, Theme.dark ? 0.35 : 0.22) + } + + Rectangle { + anchors.fill: parent + radius: Theme.radius.xl + border.width: 1 + border.color: Qt.rgba(1, 1, 1, 0.08) + gradient: Gradient { + GradientStop { position: 0.0; color: Format.shade(root.iconTint, Theme.dark ? 0.34 : 0.84, 0.55) } + GradientStop { position: 1.0; color: Format.shade(root.iconTint, Theme.dark ? 0.18 : 0.72, 0.55) } + } + + Image { + anchors.centerIn: parent + width: root.slim ? 40 : root.compact ? 68 : 88 + height: width + source: root.iconKey.length > 0 ? "image://instanceicon/" + root.iconKey : "" + sourceSize: Qt.size(width, height) + fillMode: Image.PreserveAspectFit + } + } + } + + Column { + anchors.verticalCenter: parent.verticalCenter + width: parent.width - tile.width - parent.spacing + spacing: root.slim ? Theme.space.xs : Theme.space.sm + + Text { + // Compact no longer blanks this outright: the page decides + // what, if anything, `overline` says (see the file + // comment), and an empty string already renders as nothing. + visible: root.overline.length > 0 && !root.slim + text: root.overline.toUpperCase() + color: Theme.palette.accent + font.family: Theme.font.family + font.pixelSize: Theme.type.overline.pixelSize + font.weight: Font.Bold + font.letterSpacing: Theme.type.overline.letterSpacing * 1.5 + } + + Text { + width: parent.width + text: root.name + elide: Text.ElideRight + // Over the photo's dark fade the text is light in both + // themes; see Theme.media. + color: root.hasPhoto ? Theme.media.text : Theme.palette.textPrimary + font.family: Theme.font.family + font.pixelSize: root.slim ? Theme.type.heading.pixelSize + 2 + : root.compact ? Theme.type.display.pixelSize - 4 : Theme.type.display.pixelSize + 2 + font.weight: Font.Bold + font.letterSpacing: -0.5 + } + + Row { + spacing: Theme.space.sm + + Tag { + text: root.loader.length > 0 ? root.loader : qsTr("Vanilla") + iconName: "layers" + onMedia: root.hasPhoto + } + Tag { + visible: root.gameVersion.length > 0 + text: root.gameVersion + iconName: "cube" + onMedia: root.hasPhoto + } + Tag { + text: Format.lastPlayed(root.lastLaunch) + iconName: "clock" + onMedia: root.hasPhoto + } + Tag { + readonly property string played: Format.playTime(root.totalTimePlayed) + visible: played.length > 0 + text: qsTr("%1 played").arg(played) + onMedia: root.hasPhoto + } + } + + // Zero height, but the column's spacing around it doubles the + // gap between the tags and the buttons. + Item { width: 1; height: 0; visible: !root.slim } + Loader { + active: !root.slim + visible: active + sourceComponent: actionsComponent + } + + Column { + visible: root.launching + width: Math.min(parent.width, 360) + spacing: Theme.space.xs + + Text { + width: parent.width + text: root.launchProgress >= 0 + ? qsTr("%1 · %2%").arg(root.launchStatus).arg(Math.round(root.launchProgress * 100)) + : root.launchStatus + elide: Text.ElideRight + color: root.hasPhoto ? Theme.media.textSecondary : Theme.palette.textSecondary + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + } + + LaunchProgressBar { + width: parent.width + progress: root.launchProgress + } + } + } + } + + // The actions, below the title normally and on the right when slim. + Component { + id: actionsComponent + Row { + spacing: Theme.space.sm + + PlayButton { + visible: root.showPlay + round: false + size: Theme.control.heightLg + running: root.isRunning + busy: root.launching + enabled: !root.launching && (root.isRunning || root.canLaunch) + onClicked: root.isRunning ? root.stopRequested() : root.playRequested() + } + + Button { + visible: root.showEdit + height: Theme.control.heightLg + text: root.editText + icon.source: Icons.url("settings") + onClicked: root.editRequested() + } + + IconButton { + size: Theme.control.heightLg + flat: false + iconName: "folder" + tip: qsTr("Open folder") + onClicked: root.folderRequested() + } + + IconButton { + visible: root.showMenu + size: Theme.control.heightLg + flat: false + iconName: "more" + tip: qsTr("More") + onClicked: root.menuRequested() + } + } + } + + Loader { + id: slimActions + active: root.slim + visible: active + anchors.right: parent.right + anchors.rightMargin: Theme.space.lg + anchors.verticalCenter: parent.verticalCenter + sourceComponent: actionsComponent + } + + // Drawn last: children paint over the root's own border, and the art + // fills the whole card. + Rectangle { + id: outline + anchors.fill: parent + radius: root.radius + color: "transparent" + border.width: 1 + border.color: root.hasPhoto ? Theme.media.chipBorder : Theme.palette.border + } +} diff --git a/launcher/qml/Components/CoverArt.qml b/launcher/qml/Components/CoverArt.qml new file mode 100644 index 00000000..7f1c7e4b --- /dev/null +++ b/launcher/qml/Components/CoverArt.qml @@ -0,0 +1,203 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import MeshMC.Theme + +/* + * Art behind one instance: its own newest screenshot when it has one, or -- + * when it does not -- one of PixelArt's original pixel-art landscapes, + * picked deterministically from `seed` (an instance/world id) so a given + * item always draws the same scene rather than a new one on every repaint. + * Used as the whole cover of an InstanceCard and, full-bleed, behind + * ContinueCard, InstancePage's hero and PlayDock's bar. Every scene exists + * at three shapes (see `artShape`): cropping the 16:9 one into a bar far + * wider than it only ever samples a sliver of plain sky, so a wide host is + * given the scene drawn for its own shape instead. + * + * ROUNDED CORNERS. Rectangle's `clip` only clips to the bounding box, and + * below Qt 6.5 there is no MultiEffect/ShaderEffect-free way to mask an + * Image to a rounded shape. So the photo fills the whole item, square, and + * a ring drawn in `matte` -- the colour of whatever is behind this item -- + * paints over the four corners outside the rounded outline (see `corners`). + * The caller passes the colour its own background has at that spot. + */ +Item { + id: root + + // Screenshot to show, or "" for the generated fallback below. + property url source: "" + // The instance's own icon tint (InstanceList's iconTint role), driving + // both the fallback plate and its pattern. + property color tint: Theme.palette.textTertiary + // image://instanceicon/, for the fallback's centred icon. + property string iconKey: "" + property int iconSize: 64 + // The instance/world id driving which fallback landscape is picked; + // falls back to iconKey (still deterministic, just coarser-grained) when + // a caller has not been updated to pass its own id. + property string seed: "" + property int radius: Theme.radius.md + 2 + // "none" | "bottom" | "horizontal" -- a dark fade so text/controls laid + // over the art stay legible, whether that art is a real screenshot or + // the generated pixel-art fallback. "bottom" suits a card (controls sit + // at the cover's foot); "horizontal" suits a hero (the fade favours the + // leading edge, where its text sits). + property string scrim: "none" + // Sets on the caller's own hover state -- true nudges the photo into + // its subtle zoom. + property bool hovered: false + // 1 decodes the photo near its displayed size (sharp, for a card/hero). + // A caller compositing it as a faint background wash (PageBackdrop) can + // pass something smaller: decoding at a fraction of the size and then + // stretching that small bitmap back up (Image's own bilinear filtering) + // softens hard photo edges -- roofs, tree lines -- into a wash instead + // of a blotchy low-opacity smudge, with no blur shader (Qt 6.4 floor). + property real photoSoftness: 1.0 + // What is painted behind this item's corners: the card or page colour. + // Covers the photo's square corners; see the file comment. + property color matte: Theme.palette.canvas + + readonly property bool hasPhoto: root.source.toString().length > 0 + // Which of PixelArt's three drawn shapes fits this item: "card" for a + // grid card or tile, "band" for the instance page's hero, "strip" for the + // dock bar. + readonly property real aspect: root.height > 0 ? root.width / root.height : 1 + readonly property string artShape: PixelArt.shapeForAspect(root.aspect) + readonly property string fallbackKey: root.seed.length > 0 ? root.seed : root.iconKey + readonly property url fallbackArt: PixelArt.landscapeUrl(root.fallbackKey, root.artShape) + // The zoomed photo would otherwise overshoot these bounds slightly. + clip: true + + // The fallback art; under a photo it is only the placeholder shown + // while the photo loads. + Rectangle { + id: backdrop + anchors.fill: parent + radius: root.radius + color: Format.shade(root.tint, Theme.dark ? 0.20 : 0.85, 0.5) + clip: true + + // One of PixelArt's 24 generated scenes, deterministic per `seed` + // (and mirrored for about half of them) -- see the file comment. + // Nearest-neighbour (smooth: false) keeps its native pixels crisp + // instead of letting the scene graph blur them on the upscale. + Image { + anchors.fill: parent + visible: !root.hasPhoto + source: root.hasPhoto ? "" : root.fallbackArt + fillMode: Image.PreserveAspectCrop + mirror: PixelArt.landscapeMirror(root.fallbackKey) + smooth: false + asynchronous: true + cache: true + } + + // Dark theme: the pastel day scenes would otherwise glare against + // the graphite surfaces around them. Static, and only over the + // generated art -- a real screenshot is left as it was taken. + Rectangle { + anchors.fill: parent + visible: !root.hasPhoto && Theme.dark + color: Qt.rgba(0, 0, 0, 0.22) + } + + // A small corner badge for the instance icon rather than a giant + // centred medallion -- same corner-anchored, icon-sized idiom + // ModpackCard's own logoFrame uses, so the scene behind stays the + // dominant visual and the icon reads as a badge over it. + readonly property int badgeSize: Math.min(root.iconSize, 44) + + Rectangle { + id: iconChip + visible: !root.hasPhoto && root.iconKey.length > 0 + anchors.left: parent.left + anchors.bottom: parent.bottom + anchors.margins: Theme.space.sm + width: backdrop.badgeSize + height: width + radius: Theme.radius.md + color: Qt.rgba(0, 0, 0, Theme.dark ? 0.34 : 0.22) + + Image { + anchors.fill: parent + anchors.margins: 6 + source: root.iconKey.length > 0 ? "image://instanceicon/" + root.iconKey : "" + sourceSize: Qt.size(width, height) + fillMode: Image.PreserveAspectFit + asynchronous: true + // Pixel art: never let the scene graph filter it into a blur. + smooth: false + } + } + } + + Image { + id: photo + anchors.fill: parent + visible: opacity > 0 + opacity: 0 + source: root.hasPhoto ? root.source : "" + fillMode: Image.PreserveAspectCrop + asynchronous: true + cache: true + // A fixed 2x cap rather than the raw item size: sharp on a retina + // display without ever decoding a screenshot at its full + // resolution for what is, at most, a hero-sized crop. Scaled down + // further by photoSoftness for a caller that wants a soft wash + // rather than a sharp photo. + sourceSize: Qt.size(Math.max(8, width * 2 * root.photoSoftness), Math.max(8, height * 2 * root.photoSoftness)) + scale: root.hovered ? 1.04 : 1.0 + transformOrigin: Item.Center + + onStatusChanged: if (status === Image.Ready) opacity = 1 + + Behavior on opacity { NumberAnimation { duration: Theme.motion.slow; easing.type: Theme.motion.easing } } + Behavior on scale { NumberAnimation { duration: Theme.motion.slow; easing.type: Theme.motion.easing } } + } + + // Theme.media.scrim rather than the palette's scrim: the text on top is + // light in both themes, so the fade has to be dark in both. + readonly property color scrimOpaque: Theme.media.scrim + readonly property color scrimClear: Qt.rgba(scrimOpaque.r, scrimOpaque.g, scrimOpaque.b, 0) + + Rectangle { + anchors.fill: parent + // Not gated on hasPhoto: the fallback landscape is real imagery too + // and wants the same legibility fade a caller asks for over a + // real screenshot. + visible: root.scrim === "bottom" + gradient: Gradient { + GradientStop { position: 0.0; color: Qt.rgba(root.scrimOpaque.r, root.scrimOpaque.g, root.scrimOpaque.b, 0.18) } + GradientStop { position: 0.45; color: root.scrimClear } + GradientStop { position: 1.0; color: Qt.rgba(root.scrimOpaque.r, root.scrimOpaque.g, root.scrimOpaque.b, 0.62) } + } + } + + Rectangle { + anchors.fill: parent + visible: root.scrim === "horizontal" + gradient: Gradient { + orientation: Gradient.Horizontal + GradientStop { position: 0.0; color: root.scrimOpaque } + GradientStop { position: 0.45; color: Qt.rgba(root.scrimOpaque.r, root.scrimOpaque.g, root.scrimOpaque.b, 0.62) } + GradientStop { position: 1.0; color: Qt.rgba(root.scrimOpaque.r, root.scrimOpaque.g, root.scrimOpaque.b, 0.12) } + } + } + + // The rounded mask: a ring whose inner edge is this item's rounded + // outline, painted in the colour behind the item. Only its inner part + // falls inside the item, and that is exactly the four corners. + Rectangle { + id: corners + readonly property int ring: root.radius + 2 + visible: root.radius > 0 + anchors.fill: parent + anchors.margins: -ring + radius: root.radius + ring + color: "transparent" + border.width: ring + border.color: root.matte + } +} diff --git a/launcher/qml/Components/DataPacksTab.qml b/launcher/qml/Components/DataPacksTab.qml new file mode 100644 index 00000000..53155490 --- /dev/null +++ b/launcher/qml/Components/DataPacksTab.qml @@ -0,0 +1,222 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Dialogs +import MeshMC.Theme + +/* + * Data packs for one of this instance's worlds (saves//datapacks) -- + * the widget-free replacement for the modal dialog WorldListPage's "Data + * packs" action used to open. The world picker defaults to the first world; + * WorldsTab's own "Data packs" row action points this at a specific one via + * `selectedWorldRow`. + */ +Item { + id: root + + // InstanceDetails: worlds, worldDataPacks (WorldDataPacksController). + property var details: null + // Set from outside (WorldsTab's per-row action) to jump straight to a + // world; -1 means "whatever the picker currently shows" (defaults to 0). + property int selectedWorldRow: -1 + signal openFolderRequested(string path) + + readonly property var worldsModel: root.details ? root.details.worlds : null + readonly property var controller: root.details ? root.details.worldDataPacks : null + readonly property bool unlocked: !!root.controller && root.controller.unlocked + readonly property int count: list.count + + function openWorld(row) { + if (!root.controller || row < 0) + return + picker.currentIndex = row + root.controller.openForWorld(row) + } + + onSelectedWorldRowChanged: if (selectedWorldRow >= 0) openWorld(selectedWorldRow) + // Fires at creation too (see ManagedPackTab.qml's onControllerChanged + // for why that still counts) and again whenever a different instance's + // controller replaces this one. + onControllerChanged: openDefault() + function openDefault() { + if (root.selectedWorldRow >= 0) + openWorld(root.selectedWorldRow) + else if (root.worldsModel && picker.count > 0) + openWorld(0) + } + + ColumnLayout { + anchors.fill: parent + spacing: Theme.space.md + visible: picker.count > 0 + + RowLayout { + Layout.fillWidth: true + spacing: Theme.space.sm + + Text { + text: qsTr("World:") + color: Theme.palette.textSecondary + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + } + ComboBox { + id: picker + Layout.preferredWidth: 220 + model: root.worldsModel + textRole: "name" + onActivated: (index) => root.openWorld(index) + } + Text { + Layout.fillWidth: true + text: !root.unlocked && root.controller ? qsTr("The game is running; data packs can be changed once it has closed.") : "" + color: Theme.palette.warning + elide: Text.ElideRight + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + } + Button { + text: qsTr("Open folder") + icon.source: Icons.url("folder") + enabled: root.controller && root.controller.ready + onClicked: root.openFolderRequested(root.controller ? root.controller.directory : "") + } + Button { + enabled: root.unlocked && root.controller && root.controller.ready + text: qsTr("Add from file…") + icon.source: Icons.url("plus") + onClicked: fileDialog.open() + } + } + + ListView { + id: list + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + spacing: Theme.space.xs + boundsBehavior: Flickable.StopAtBounds + model: root.controller ? root.controller.model : null + ScrollBar.vertical: ScrollBar {} + + delegate: Rectangle { + id: row + required property int index + required property var model + readonly property string name: model.name + readonly property var version: model.version + readonly property bool itemEnabled: model.enabled + + width: list.width - Theme.space.md + height: Theme.control.heightLg + Theme.space.md + radius: Theme.radius.md + color: hover.hovered ? Theme.palette.surfaceRaised : Theme.palette.surface + border.width: 1 + border.color: Theme.palette.border + + Behavior on color { ColorAnimation { duration: Theme.motion.fast } } + HoverHandler { id: hover } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: Theme.space.sm + anchors.rightMargin: Theme.space.sm + spacing: Theme.space.md + + Switch { + checked: row.itemEnabled + enabled: root.unlocked + Accessible.name: qsTr("Enable %1").arg(row.name) + onToggled: { + root.controller.setEnabled(row.index, checked) + checked = Qt.binding(() => row.itemEnabled) + } + } + + Rectangle { + Layout.preferredWidth: 36 + Layout.preferredHeight: 36 + radius: Theme.radius.md + color: Theme.palette.surfaceSunken + opacity: row.itemEnabled ? 1 : Theme.opacity.disabled + MeshIcon { anchors.centerIn: parent; iconName: "package"; size: Theme.icon.sm; color: Theme.palette.textTertiary } + } + + Column { + Layout.fillWidth: true + Layout.minimumWidth: 0 + spacing: 1 + Text { + width: parent.width + text: row.name + elide: Text.ElideRight + color: row.itemEnabled ? Theme.palette.textPrimary : Theme.palette.textTertiary + font.family: Theme.font.family + font.pixelSize: Theme.type.body.pixelSize + font.weight: Font.Medium + } + Text { + width: parent.width + visible: text.length > 0 + text: row.version ? String(row.version) : "" + elide: Text.ElideRight + color: Theme.palette.textTertiary + font.family: Theme.font.family + font.pixelSize: Theme.type.caption.pixelSize + } + } + + IconButton { + visible: hover.hovered && root.unlocked + iconName: "trash" + tip: qsTr("Remove") + onClicked: { + confirm.row = row.index + confirm.text = qsTr("Remove “%1” from this world? The file is deleted.").arg(row.name) + confirm.open() + } + } + } + } + } + } + + ConfirmDialog { + id: confirm + property int row: -1 + title: qsTr("Remove data pack") + confirmText: qsTr("Remove") + onConfirmed: if (root.controller) root.controller.remove(row) + } + + EmptyState { + anchors.centerIn: parent + upperThird: true + visible: picker.count === 0 + title: qsTr("No worlds yet") + body: qsTr("Data packs live inside a world's own folder, so create a world first.") + MeshIcon { iconName: "package"; size: 40; color: Theme.palette.textTertiary } + } + + EmptyState { + anchors.centerIn: parent + upperThird: true + visible: picker.count > 0 && list.count === 0 + title: qsTr("No data packs") + body: qsTr("Add one from a file to enable it for this world.") + actionText: qsTr("Add from file…") + onActionTriggered: fileDialog.open() + MeshIcon { iconName: "package"; size: 40; color: Theme.palette.textTertiary } + } + + FileDialog { + id: fileDialog + title: qsTr("Add data pack") + nameFilters: [qsTr("Data pack files (*.zip)"), qsTr("All files (*)")] + onAccepted: if (root.controller) root.controller.install(selectedFile.toString()) + } +} diff --git a/launcher/qml/Components/DialogHeader.qml b/launcher/qml/Components/DialogHeader.qml new file mode 100644 index 00000000..ffe59c3e --- /dev/null +++ b/launcher/qml/Components/DialogHeader.qml @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import MeshMC.Theme + +/* + * A dialog's title row with an optional colour-tinted icon badge leading + * the title -- for the handful of dialogs whose title alone doesn't say + * enough (a destructive confirm, a host request carrying its own + * severity). Set as `header:` only where the badge earns its place; + * everything else keeps Style/Dialog.qml's plain title-only header. + */ +Item { + id: root + + property string title + property string icon: "" + property color iconColor: Theme.palette.accent + + implicitHeight: Theme.space.lg + Math.max(Theme.control.height, label.implicitHeight) + + Rectangle { + id: badge + visible: root.icon.length > 0 + x: Theme.space.lg + anchors.verticalCenter: label.verticalCenter + width: Theme.control.height + height: Theme.control.height + radius: Theme.radius.md + color: Qt.rgba(root.iconColor.r, root.iconColor.g, root.iconColor.b, 0.16) + + MeshIcon { + anchors.centerIn: parent + iconName: root.icon + size: Theme.icon.md + color: root.iconColor + } + } + + Label { + id: label + y: Theme.space.lg + anchors.left: badge.visible ? badge.right : parent.left + anchors.leftMargin: badge.visible ? Theme.space.sm : Theme.space.lg + anchors.right: parent.right + anchors.rightMargin: Theme.space.lg + text: root.title + elide: Label.ElideRight + font.pixelSize: Theme.type.title.pixelSize + font.weight: Theme.type.title.weight + } +} diff --git a/launcher/qml/Components/DiscoverPage.qml b/launcher/qml/Components/DiscoverPage.qml new file mode 100644 index 00000000..2870f7bc --- /dev/null +++ b/launcher/qml/Components/DiscoverPage.qml @@ -0,0 +1,326 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import MeshMC.Theme + +/* + * Browse modpacks and install them as new instances, without leaving the + * main window. Modrinth for now -- no account or key needed; a pack from + * another platform (CurseForge, FTB, ATLauncher, Technic) can be imported + * from its exported .zip via the New instance dialog's Import mode, one + * click away; browsing those catalogues here is not built yet. + * + * `model` is the shell's ModrinthModpackModel: query/loader/sort in, + * search()/fetchMore() to run, rows out, plus a lazily loaded `detail`. + */ +Item { + id: root + + property var model: null + property var installer: null + signal showInstanceRequested(string id) + signal otherPlatformsRequested() + + property var openedPack: null + + readonly property var loaders: [ + { value: "", label: qsTr("Any") }, + { value: "fabric", label: "Fabric" }, + { value: "forge", label: "Forge" }, + { value: "neoforge", label: "NeoForge" }, + { value: "quilt", label: "Quilt" } + ] + + // Opens the index-th result as if it were clicked. + function openResult(index) { + var item = resultsRepeater.itemAt(index) + if (item) + item.clicked() + } + + function open(pack) { + root.openedPack = pack + if (root.model) + root.model.loadDetail(pack.projectId) + } + + // Set once a search has been asked for, so "nothing found" is never + // shown for a search that has not happened. + property bool searched: false + + function runSearch() { + if (!root.model) + return + root.searched = true + root.model.search() + } + + // First search when the page is first shown -- not at startup, so + // opening the launcher never talks to Modrinth on its own. Gated on + // StackLayout's own isCurrentItem (Qt 6.4+), not `visible`: `visible` + // is still true at Component.onCompleted (StackLayout only flips the + // non-current pages' visible off on its next polish, after every page's + // own onCompleted has already run), so checking it here fired a search + // on every startup regardless of which page was actually shown. Falls + // back to `visible` if a future QtQuick.Layouts build ever drops the + // attached property. + function searchIfFirstShown() { + var current = root.StackLayout && root.StackLayout.isCurrentItem !== undefined + ? root.StackLayout.isCurrentItem : root.visible + if (current && !root.searched) + runSearch() + } + StackLayout.onIsCurrentItemChanged: searchIfFirstShown() + onModelChanged: searchIfFirstShown() + Component.onCompleted: searchIfFirstShown() + + // Typing searches once the user pauses, not per keystroke. + Timer { + id: debounce + interval: 350 + onTriggered: { + if (!root.model) + return + root.model.query = searchField.text + root.runSearch() + } + } + + // A chrome-only header region (design-plan.md §5): before any result has + // come back, or once a search truly has none, there is no cover art of + // the page's own to bleed behind the toolbar the way Library/PlayDock + // do -- the same quiet wash Settings uses instead. Sits behind the + // StackLayout below, not inside its flow. + AmbientPattern { + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + height: 220 + fadeBottom: true + visible: !root.openedPack && (!root.model || root.model.count === 0) + } + + StackLayout { + anchors.fill: parent + currentIndex: root.openedPack ? 1 : 0 + + // Results + ColumnLayout { + spacing: Theme.space.lg + + // Search, loader, sort and import in one toolbar row -- a + // RowLayout, not a wrapping Flow: a Flow that runs out of room + // wraps whichever control does not fit onto its own new line, + // which for the last item (the import button) meant a whole + // row spent on one small icon. The search field gives up its + // width first, down to a floor, so everything else always has + // room on the one row -- the same rule Library's own toolbar + // uses (see TopBar.qml). + RowLayout { + id: toolbar + Layout.fillWidth: true + Layout.leftMargin: Theme.space.xl + Theme.space.xs + Layout.rightMargin: Theme.space.xl + Theme.space.xs + spacing: Theme.space.md + + SearchBox { + id: searchField + Layout.fillWidth: true + Layout.minimumWidth: 120 + placeholderText: qsTr("Search modpacks on Modrinth") + onTextEdited: debounce.restart() + onTextChanged: if (text.length === 0) debounce.restart() + } + + SegmentedControl { + id: loaderControl + options: root.loaders + current: root.model ? root.model.loader : "" + onActivated: (value) => { + if (!root.model) + return + root.model.loader = value + root.runSearch() + } + } + + ComboBox { + id: sortBox + Layout.preferredWidth: 200 + visible: count > 0 + model: root.model && root.model.sortOptions ? root.model.sortOptions : [] + textRole: "label" + valueRole: "id" + Component.onCompleted: if (root.model) currentIndex = Math.max(0, indexOfValue(root.model.sort)) + onActivated: { + if (!root.model) + return + root.model.sort = currentValue + root.runSearch() + } + } + + IconButton { + id: otherPlatformsButton + flat: false + iconName: "download" + tip: qsTr("Import a pack exported from CurseForge, FTB, ATLauncher or Technic") + onClicked: root.otherPlatformsRequested() + } + } + + // Results, as a responsive card grid -- 2 to 4 columns + // depending on width, same breakpoint math LibraryPage uses + // for the instance grid. + Flickable { + id: flick + Layout.fillWidth: true + Layout.fillHeight: true + contentWidth: width + contentHeight: content.y + content.height + Theme.space.xl + boundsBehavior: Flickable.StopAtBounds + clip: true + ScrollBar.vertical: ScrollBar {} + + // QML views never ask for more on their own; ask as the + // end comes into view. + onAtYEndChanged: if (atYEnd && root.model && root.model.canFetchMore && !root.model.searching) + root.model.fetchMore() + + readonly property int pagePadding: Theme.space.xl + Theme.space.xs + readonly property int gutter: Theme.space.lg + readonly property int minCardWidth: 260 + readonly property int maxCardWidth: 340 + readonly property int gridWidth: Math.max(0, width - pagePadding * 2) + readonly property int columns: Math.max(2, Math.min(4, columnsFor(gridWidth))) + readonly property real cardWidth: Math.floor((gridWidth - gutter * (columns - 1)) / columns) + + function columnsFor(w) { + var columns = Math.max(1, Math.floor((w + gutter) / (minCardWidth + gutter))) + while ((w - gutter * (columns - 1)) / columns > maxCardWidth) + columns++ + return columns + } + + Item { + id: content + x: flick.pagePadding + // Room above the first row for a card's hover lift and + // focus ring, which the flickable's clip would cut. + y: Theme.space.sm + width: flick.gridWidth + height: grid.height + + Grid { + id: grid + width: parent.width + columns: flick.columns + columnSpacing: flick.gutter + rowSpacing: flick.gutter + + Repeater { + id: resultsRepeater + model: root.model + delegate: ModpackCard { + width: flick.cardWidth + projectId: model.projectId + title: model.title + author: model.author + description: model.description + logoUrl: model.logoUrl + downloads: model.downloads + updated: model.updated + // One chip per grid card (design-plan.md + // §5/§6); the full list is on the detail + // view instead (see ModpackDetail.qml). + categories: (model.categories || []).slice(0, 1) + galleryUrl: model.galleryUrl + accentColor: model.accentColor + onClicked: root.open({ projectId: projectId, title: title, author: author, + description: description, logoUrl: logoUrl, + downloads: downloads, updated: updated, + // The card's own `categories` is already + // capped to one chip above -- the detail + // view wants the model's full list. + categories: model.categories, galleryUrl: galleryUrl, + accentColor: accentColor }) + } + } + + // A couple of rows of shimmering placeholders while + // the first page is still loading, or one trailing + // row while a further page is being fetched -- + // never a bare spinner. + Repeater { + model: root.model && root.model.searching + ? (root.model.count === 0 ? flick.columns * 2 : flick.columns) + : 0 + delegate: ModpackCard { + width: flick.cardWidth + skeleton: true + } + } + } + } + } + } + + // Detail + ModpackDetail { + pack: root.openedPack || ({}) + detail: root.model ? root.model.detail : null + model: root.model + installer: root.installer + onBackRequested: root.openedPack = null + onShowInstanceRequested: (id) => root.showInstanceRequested(id) + } + } + + EmptyState { + anchors.centerIn: parent + visible: root.searched && !root.openedPack && !!root.model && !root.model.searching && root.model.count === 0 + title: root.model && root.model.error.length > 0 ? qsTr("Couldn't reach Modrinth") + : qsTr("No modpacks found") + body: root.model && root.model.error.length > 0 ? root.model.error + : qsTr("Try fewer words, or another loader.") + actionText: root.model && root.model.error.length > 0 ? qsTr("Try again") : "" + actionIcon: "refresh" + onActionTriggered: root.runSearch() + + Column { + spacing: Theme.space.sm + + // MeshMC's own mark, small and quiet, above the state + // illustration -- one of the two places design-plan.md §1 + // reuses it as a recurring tell rather than a sidebar-only + // appearance. + BrandMark { + anchors.horizontalCenter: parent.horizontalCenter + size: 18 + opacity: 0.55 + } + // Nothing matched: the cracked-stone illustration. A failed + // request keeps the warning glyph -- that is a fault, not an + // empty result. + Image { + visible: !(root.model && root.model.error.length > 0) + anchors.horizontalCenter: parent.horizontalCenter + source: PixelArt.emptyUrl("not_found") + width: 64 + height: 64 + smooth: false + } + MeshIcon { + visible: !!(root.model && root.model.error.length > 0) + anchors.horizontalCenter: parent.horizontalCenter + iconName: "alert-triangle" + size: 40 + color: Theme.palette.textTertiary + } + } + } +} diff --git a/launcher/qml/Components/EmptyState.qml b/launcher/qml/Components/EmptyState.qml new file mode 100644 index 00000000..5a85c63b --- /dev/null +++ b/launcher/qml/Components/EmptyState.qml @@ -0,0 +1,94 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import MeshMC.Theme + +/* + * Centered "nothing here" placeholder, shared by InstanceGrid (zero + * instances) and anything else that needs the same shape later. + * + * The illustration is a default-property slot rather than an iconSource + * string: callers vary from a single glyph (Gallery, InstanceGrid) to a + * fuller drawing, and a slot lets either be dropped in without this file + * needing to know which. + */ +Item { + id: root + + property alias title: titleLabel.text + property alias body: bodyLabel.text + property string actionText: "" + property string actionIcon: "" + // Opt-in: true makes this fill its own visual parent and settle its + // content near the top third of it instead of dead centre -- for a host + // whose empty state sits in an otherwise-empty, very tall scrolling + // column (a list that would hold a handful of rows' worth of real + // content were it not empty), where centering wastes most of that + // height (design-plan.md §4 "Empty states"/§5). Every existing centred + // call site (`anchors.centerIn: parent` and similar) is unaffected: + // this only changes anything once a caller opts in, since a plain Item + // still just takes its content's own implicit size otherwise. + property bool upperThird: false + + signal actionTriggered() + + default property alias illustration: illustrationSlot.data + + implicitWidth: root.upperThird && root.parent ? root.parent.width : column.implicitWidth + implicitHeight: root.upperThird && root.parent ? root.parent.height : column.implicitHeight + + Column { + id: column + anchors.horizontalCenter: parent.horizontalCenter + // In the default (non-upperThird) mode this Item is always exactly + // column's own size, so this is 0 regardless of how a caller + // anchors the Item itself -- the same effective position + // `anchors.centerIn: parent` gave before. + y: root.upperThird ? Math.max(0, Math.round(parent.height * 0.30 - height / 2)) + : Math.round((parent.height - height) / 2) + spacing: Theme.space.md + width: Math.min(320, root.width) + + Item { + id: illustrationSlot + anchors.horizontalCenter: parent.horizontalCenter + width: childrenRect.width + height: childrenRect.height + } + + Text { + id: titleLabel + anchors.horizontalCenter: parent.horizontalCenter + horizontalAlignment: Text.AlignHCenter + color: Theme.palette.textPrimary + font.family: Theme.font.family + font.pixelSize: Theme.type.title.pixelSize + font.weight: Theme.type.title.weight + } + + Text { + id: bodyLabel + anchors.horizontalCenter: parent.horizontalCenter + width: parent.width + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.Wrap + color: Theme.palette.textSecondary + font.family: Theme.font.family + font.pixelSize: Theme.type.body.pixelSize + font.weight: Theme.type.body.weight + visible: text.length > 0 + } + + Button { + anchors.horizontalCenter: parent.horizontalCenter + text: root.actionText + highlighted: true + icon.source: root.actionIcon.length > 0 ? Icons.url(root.actionIcon) : "" + visible: root.actionText.length > 0 + onClicked: root.actionTriggered() + } + } +} diff --git a/launcher/qml/Components/Format.qml b/launcher/qml/Components/Format.qml new file mode 100644 index 00000000..5914bc47 --- /dev/null +++ b/launcher/qml/Components/Format.qml @@ -0,0 +1,129 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +pragma Singleton + +import QtQuick + +/* + * Turns the raw numbers InstanceList hands out into short, human text. + * Units are written out compactly ("3 h ago", "12 h 30 min") so no string + * here needs a plural form that an untranslated build would print as + * "hour(s)". + */ +QtObject { + // lastLaunch: milliseconds since the epoch, 0 when never launched. + function lastPlayed(lastLaunchMs) { + var ms = Number(lastLaunchMs) + if (!(ms > 0)) + return qsTr("Never played") + var minutes = Math.floor((Date.now() - ms) / 60000) + if (minutes < 1) + return qsTr("Just now") + if (minutes < 60) + return qsTr("%1 min ago").arg(minutes) + var hours = Math.floor(minutes / 60) + if (hours < 24) + return qsTr("%1 h ago").arg(hours) + var days = Math.floor(hours / 24) + if (days === 1) + return qsTr("Yesterday") + if (days < 30) + return qsTr("%1 days ago").arg(days) + return Qt.formatDate(new Date(ms), Qt.locale().dateFormat(Locale.ShortFormat)) + } + + // totalTimePlayed: seconds. + function playTime(seconds) { + var s = Number(seconds) + if (!(s >= 60)) + return "" + var minutes = Math.floor(s / 60) % 60 + var hours = Math.floor(s / 3600) + if (hours === 0) + return qsTr("%1 min").arg(minutes) + if (minutes === 0) + return qsTr("%1 h").arg(hours) + return qsTr("%1 h %2 min").arg(hours).arg(minutes) + } + + // "Fabric 1.21.4", or "1.21.4" for vanilla (loader is empty then). + function versionLine(loader, gameVersion) { + var parts = [] + if (loader && loader.length > 0) + parts.push(loader) + if (gameVersion && gameVersion.length > 0) + parts.push(gameVersion) + else if (parts.length === 0) + parts.push(qsTr("Minecraft")) + return parts.join(" ") + } + + /* + * A backdrop colour cut from an instance icon's average colour: same + * hue, saturation kept in check so neon icons don't glare, lightness + * forced to `lightness` so text on top stays readable whatever the + * icon looked like. Achromatic icons (hslHue < 0) come out grey. + */ + function shade(tint, lightness, saturationScale) { + var hue = tint.hslHue < 0 ? 0 : tint.hslHue + // Capped low: a fully saturated plate at low lightness turns into + // the murky teal/olive the launcher used to be full of. + var saturation = Math.min(0.5, tint.hslSaturation * saturationScale) + return Qt.hsla(hue, saturation, lightness, 1) + } + + // 1234 -> "1.2K", 3456789 -> "3.5M": download counts in a card. + function compactNumber(value) { + var n = Number(value) + if (!(n >= 0)) + return "" + if (n < 1000) + return String(n) + var units = ["K", "M", "B"] + var unit = -1 + while (n >= 1000 && unit < units.length - 1) { + n /= 1000 + unit++ + } + return Number(n).toLocaleString(Qt.locale(), "f", n < 10 ? 1 : 0) + units[unit] + } + + /* + * A stable colour derived from an id/name string, for a designed + * fallback that still tells two accounts (or anything else keyed by an + * id rather than an icon) apart -- the same purpose an instance's own + * randomly-assigned iconTint role serves, for something that has no + * icon of its own to carry one. Feed the result through shade() above + * for the actual fill/tint, same as any other tint this file hands out. + */ + function hashTint(seed) { + var s = String(seed || "") + var hash = 0 + for (var i = 0; i < s.length; ++i) + hash = (hash * 31 + s.charCodeAt(i)) >>> 0 + return Qt.hsla((hash % 360) / 360, 0.55, 0.55, 1) + } + + // A local path as a url QML can load: "/a/b" and "C:/a/b" alike. + function fileUrl(path) { + if (!path || path.length === 0) + return "" + var p = String(path).replace(/\\/g, "/") + return "file://" + (p.charAt(0) === "/" ? "" : "/") + p + } + + // The reverse of fileUrl() above: a "file://" url (from a FolderDialog/ + // FileDialog's selectedFolder/selectedFile) as a plain local path -- + // "file:///a/b" -> "/a/b", "file:///C:/a/b" -> "C:/a/b". + function localPath(url) { + var s = String(url) + if (s.indexOf("file://") !== 0) + return s + s = s.substring(7) + if (/^\/[A-Za-z]:/.test(s)) + s = s.substring(1) + return decodeURIComponent(s) + } +} diff --git a/launcher/qml/Components/Gallery.qml b/launcher/qml/Components/Gallery.qml new file mode 100644 index 00000000..96bb977e --- /dev/null +++ b/launcher/qml/Components/Gallery.qml @@ -0,0 +1,468 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import MeshMC.Theme + +/* + * Every component, in every state it can be in, on one scrollable page. + * This is the artifact design review happens against, so each section is + * labelled and each state called out explicitly rather than left to be + * inferred from a single "normal" example. All sample data is self + * contained -- nothing outside this file needs to feed it a model. + */ +Item { + id: root + + readonly property var iconTints: ({ + grass: "#6aa84f", + gold: "#e0b000", + diamond: "#3ec6d0", + enderman: "#30203a", + fabric: "#d9773f", + creeper: "#4b8f3e", + iron: "#b8b8b8", + stone: "#8a8a8a", + tnt: "#c0392b" + }) + + readonly property var iconNames: [ + "home", "library", "compass", "package", "settings", "search", "plus", "play", + "stop", "folder", "more", "chevron-down", "chevron-right", "user", "users", + "log-out", "refresh", "sort", "clock", "cube", "x", "check", "sun", "moon", + "external-link", "trash", "copy", "edit", "download", "grid", "list", "bell", + "info", "alert-triangle", "layers", "terminal", "image", "globe" + ] + + readonly property var navItems: [ + { id: "library", icon: "library", label: qsTr("Library") }, + { id: "modpacks", icon: "compass", label: qsTr("Discover") } + ] + readonly property var navFooterItems: [ + { id: "settings", icon: "settings", label: qsTr("Settings") } + ] + + Rectangle { + anchors.fill: parent + color: Theme.palette.canvas + } + + ListModel { + id: recentModel + ListElement { instanceId: "recent-1"; name: "Vanilla 1.21.4"; iconKey: "grass"; isRunning: false } + ListElement { instanceId: "recent-2"; name: "All the Mods 10"; iconKey: "iron"; isRunning: true } + ListElement { instanceId: "recent-3"; name: "Skyblock Extreme"; iconKey: "diamond"; isRunning: false } + } + + ListModel { + id: vanillaModel + ListElement { instanceId: "vanilla-1"; name: "Vanilla 1.21.4"; iconKey: "grass"; isRunning: false; canLaunch: true; lastLaunch: 1726000000000; gameVersion: "1.21.4"; loader: ""; iconTint: "#6aa84f"; launchStatus: ""; launchProgress: -1 } + ListElement { instanceId: "vanilla-2"; name: "Superflat Creative"; iconKey: "stone"; isRunning: false; canLaunch: true; lastLaunch: 0; gameVersion: "1.20.1"; loader: ""; iconTint: "#8a8a8a"; launchStatus: ""; launchProgress: -1 } + ListElement { instanceId: "vanilla-3"; name: "Hardcore Survival"; iconKey: "tnt"; isRunning: false; canLaunch: true; lastLaunch: 1706000000000; gameVersion: "1.19.4"; loader: ""; iconTint: "#c0392b"; launchStatus: ""; launchProgress: -1 } + } + + ListModel { + id: moddedModel + ListElement { instanceId: "modded-1"; name: "All the Mods 10"; iconKey: "iron"; isRunning: true; canLaunch: true; lastLaunch: 1726000000000; gameVersion: "1.20.1"; loader: "Fabric"; iconTint: "#b8b8b8"; launchStatus: ""; launchProgress: -1 } + ListElement { instanceId: "modded-2"; name: "Create: Above and Beyond"; iconKey: "fabric"; isRunning: false; canLaunch: true; lastLaunch: 1706000000000; gameVersion: "1.18.2"; loader: "Fabric"; iconTint: "#d9773f"; launchStatus: ""; launchProgress: -1 } + ListElement { instanceId: "modded-3"; name: "Enderman Challenge"; iconKey: "enderman"; isRunning: false; canLaunch: true; lastLaunch: 1706000000000; gameVersion: "1.21.1"; loader: "Forge"; iconTint: "#30203a"; launchStatus: ""; launchProgress: -1 } + } + + ScrollView { + anchors.fill: parent + contentWidth: availableWidth + clip: true + + ColumnLayout { + width: root.width + spacing: Theme.space.xxl + + SectionHeader { + Layout.topMargin: Theme.space.lg + Layout.leftMargin: Theme.space.lg + title: qsTr("Top Bar") + collapsible: false + } + + TopBar { + Layout.fillWidth: true + Layout.leftMargin: Theme.space.lg + Layout.rightMargin: Theme.space.lg + title: qsTr("Instances") + count: 12 + searchText: "vanilla" + + Button { + text: qsTr("New instance") + highlighted: true + } + } + + SectionHeader { + Layout.leftMargin: Theme.space.lg + title: qsTr("Sidebar Nav") + collapsible: false + } + + RowLayout { + Layout.leftMargin: Theme.space.lg + + SidebarNav { + Layout.preferredWidth: 244 + Layout.preferredHeight: 420 + items: root.navItems + footerItems: root.navFooterItems + currentId: "library" + recentModel: recentModel + accountName: "Steve" + accountKind: "Microsoft" + } + } + + // Nav Item states: unselected, selected. + RowLayout { + Layout.leftMargin: Theme.space.lg + spacing: Theme.space.sm + + NavItem { iconName: "library"; label: qsTr("Unselected") } + NavItem { iconName: "library"; label: qsTr("Selected"); selected: true } + } + + // Account Chip states: signed in (Microsoft), signed in (Offline), signed out. + RowLayout { + Layout.leftMargin: Theme.space.lg + spacing: Theme.space.lg + + AccountChip { name: "Steve"; kind: "Microsoft" } + AccountChip { name: "Steve"; kind: "Offline" } + AccountChip { name: ""; kind: "" } + } + + SectionHeader { + Layout.leftMargin: Theme.space.lg + title: qsTr("Status Badge") + collapsible: false + } + + RowLayout { + Layout.leftMargin: Theme.space.lg + spacing: Theme.space.sm + + StatusBadge { text: qsTr("Local"); tone: "neutral" } + StatusBadge { text: qsTr("Up to date"); tone: "success" } + StatusBadge { text: qsTr("Update available"); tone: "warning" } + StatusBadge { text: qsTr("Broken"); tone: "danger" } + StatusBadge { text: qsTr("Modded"); tone: "info" } + } + + SectionHeader { + Layout.leftMargin: Theme.space.lg + title: qsTr("Buttons") + collapsible: false + } + + RowLayout { + Layout.leftMargin: Theme.space.lg + spacing: Theme.space.md + + PlayButton { } + PlayButton { running: true } + PlayButton { size: Theme.control.height } + PlayButton { round: false } + PlayButton { round: false; running: true } + IconButton { iconName: "folder"; tip: qsTr("Open folder") } + IconButton { iconName: "more"; tip: qsTr("More"); flat: false } + IconButton { iconName: "refresh"; tip: qsTr("Refresh"); size: Theme.control.heightLg } + } + + SectionHeader { + Layout.leftMargin: Theme.space.lg + title: qsTr("Controls") + collapsible: false + } + + GridLayout { + Layout.leftMargin: Theme.space.lg + Layout.rightMargin: Theme.space.lg + Layout.fillWidth: true + columns: 3 + columnSpacing: Theme.space.xl + rowSpacing: Theme.space.lg + + TextField { + Layout.preferredWidth: 220 + placeholderText: qsTr("Text field") + } + ComboBox { + Layout.preferredWidth: 220 + model: [qsTr("First option"), qsTr("Second option"), qsTr("Third option")] + } + SpinBox { + Layout.preferredWidth: 140 + from: 0 + to: 100 + value: 42 + } + + RowLayout { + spacing: Theme.space.lg + CheckBox { text: qsTr("Unchecked") } + CheckBox { text: qsTr("Checked"); checked: true } + } + RowLayout { + spacing: Theme.space.lg + RadioButton { text: qsTr("Off") } + RadioButton { text: qsTr("On"); checked: true } + } + RowLayout { + spacing: Theme.space.lg + Switch { text: qsTr("Off") } + Switch { text: qsTr("On"); checked: true } + } + + Slider { + Layout.preferredWidth: 220 + value: 0.6 + } + SegmentedControl { + options: [ { value: "a", label: qsTr("Day") }, { value: "b", label: qsTr("Week") }, { value: "c", label: qsTr("Month") } ] + current: "b" + } + TabStrip { + tabs: [ { id: "one", label: qsTr("Mods"), count: 6 }, { id: "two", label: qsTr("Worlds") }, { id: "three", label: qsTr("Log") } ] + current: "one" + } + } + + SectionHeader { + Layout.leftMargin: Theme.space.lg + title: qsTr("Dialog Header") + collapsible: false + } + + RowLayout { + Layout.leftMargin: Theme.space.lg + Layout.rightMargin: Theme.space.lg + spacing: Theme.space.md + + Repeater { + model: [ + { title: qsTr("Delete world"), icon: "alert-triangle", tint: Theme.palette.danger }, + { title: qsTr("Update available"), icon: "download", tint: Theme.palette.accent }, + { title: qsTr("Choose an icon"), icon: "image", tint: Theme.palette.accent } + ] + + delegate: Rectangle { + required property var modelData + Layout.preferredWidth: 280 + Layout.preferredHeight: 64 + radius: Theme.radius.xl + color: Theme.palette.surfaceOverlay + border.width: 1 + border.color: Theme.palette.border + + DialogHeader { + anchors.fill: parent + title: modelData.title + icon: modelData.icon + iconColor: modelData.tint + } + } + } + } + + SectionHeader { + Layout.leftMargin: Theme.space.lg + title: qsTr("Tag & Search Field") + collapsible: false + } + + RowLayout { + Layout.leftMargin: Theme.space.lg + spacing: Theme.space.sm + + Tag { text: "Fabric"; iconName: "layers" } + Tag { text: "1.21.4"; iconName: "cube" } + Tag { text: qsTr("2 h ago"); iconName: "clock" } + + Rectangle { + Layout.preferredWidth: 150 + Layout.preferredHeight: Theme.control.heightLg + radius: Theme.radius.md + color: Theme.palette.textPrimary + + Tag { + anchors.centerIn: parent + text: qsTr("On media") + iconName: "check" + onMedia: true + } + } + + SearchBox { + Layout.preferredWidth: 220 + placeholderText: qsTr("Search") + } + } + + SectionHeader { + Layout.leftMargin: Theme.space.lg + title: qsTr("Instance Card") + collapsible: false + } + + RowLayout { + Layout.leftMargin: Theme.space.lg + Layout.rightMargin: Theme.space.lg + spacing: Theme.space.md + + // Every state InstanceCard can be in: rest, forceHovered, + // selected, running, never played, launching. + Repeater { + model: [ + { instanceId: "card-rest", name: qsTr("Vanilla 1.21.4"), iconKey: "grass", version: "1.21.4", loader: "", last: Date.now() - 2 * 86400000, running: false, hovered: false, selected: false }, + { instanceId: "card-hovered", name: qsTr("Skyblock Extreme"), iconKey: "diamond", version: "1.20.1", loader: "Fabric", last: Date.now(), running: false, hovered: true, selected: false }, + { instanceId: "card-selected", name: qsTr("A very long modpack name that needs to wrap"), iconKey: "gold", version: "1.20.1", loader: "Forge", last: Date.now() - 3 * 86400000, running: false, hovered: false, selected: true }, + { instanceId: "card-running", name: qsTr("Create: Above and Beyond"), iconKey: "enderman", version: "1.18.2", loader: "Fabric", last: Date.now(), running: true, hovered: false, selected: false }, + { instanceId: "card-never-played", name: qsTr("Superflat Creative"), iconKey: "stone", version: "1.20.1", loader: "", last: 0, running: false, hovered: false, selected: false }, + { instanceId: "card-launching", name: qsTr("Better Minecraft"), iconKey: "tnt", version: "1.20.1", loader: "Forge", last: 0, running: false, hovered: false, selected: false, status: qsTr("Downloading assets"), progress: 0.45 } + ] + + delegate: InstanceCard { + required property var modelData + Layout.preferredWidth: 208 + instanceId: modelData.instanceId + name: modelData.name + iconKey: modelData.iconKey + isRunning: modelData.running + canLaunch: true + lastLaunch: modelData.last + gameVersion: modelData.version + loader: modelData.loader + iconTint: root.iconTints[modelData.iconKey] + forceHovered: modelData.hovered + selected: modelData.selected + launchStatus: modelData.status || "" + launchProgress: modelData.progress !== undefined ? modelData.progress : -1 + } + } + } + + SectionHeader { + Layout.leftMargin: Theme.space.lg + title: qsTr("Continue Card") + collapsible: false + } + + ContinueCard { + Layout.fillWidth: true + Layout.leftMargin: Theme.space.lg + Layout.rightMargin: Theme.space.lg + instanceId: "continue-1" + name: qsTr("All the Mods 10") + iconKey: "iron" + isRunning: false + canLaunch: true + lastLaunch: Date.now() - 3600000 + totalTimePlayed: 5 * 3600 + 20 * 60 + gameVersion: "1.20.1" + loader: "Fabric" + iconTint: root.iconTints.iron + launchStatus: "" + launchProgress: -1 + } + + SectionHeader { + Layout.leftMargin: Theme.space.lg + title: qsTr("Instance Section") + collapsible: false + } + + InstanceSection { + Layout.fillWidth: true + Layout.leftMargin: Theme.space.lg + Layout.rightMargin: Theme.space.lg + title: qsTr("Vanilla") + model: vanillaModel + columns: 3 + cardWidth: 208 + } + + InstanceSection { + Layout.fillWidth: true + Layout.leftMargin: Theme.space.lg + Layout.rightMargin: Theme.space.lg + title: qsTr("Modded") + model: moddedModel + columns: 3 + cardWidth: 208 + } + + SectionHeader { + Layout.leftMargin: Theme.space.lg + title: qsTr("Empty State") + collapsible: false + } + + EmptyState { + Layout.fillWidth: true + Layout.preferredHeight: 220 + title: qsTr("No instances yet") + body: qsTr("Create an instance to see it here.") + actionText: qsTr("Create instance") + + MeshIcon { + iconName: "package" + size: Theme.icon.lg * 2 + color: Theme.palette.textTertiary + } + } + + SectionHeader { + Layout.leftMargin: Theme.space.lg + title: qsTr("Icons") + collapsible: false + } + + Grid { + Layout.leftMargin: Theme.space.lg + Layout.rightMargin: Theme.space.lg + columns: 8 + columnSpacing: Theme.space.lg + rowSpacing: Theme.space.lg + + Repeater { + model: root.iconNames + + delegate: Column { + required property string modelData + width: 64 + spacing: Theme.space.xs + + MeshIcon { + anchors.horizontalCenter: parent.horizontalCenter + iconName: parent.modelData + size: Theme.icon.lg + } + + Text { + width: parent.width + horizontalAlignment: Text.AlignHCenter + text: parent.modelData + color: Theme.palette.textTertiary + font.family: Theme.font.family + font.pixelSize: Theme.type.caption.pixelSize + } + } + } + } + + Item { Layout.preferredHeight: Theme.space.xxl } + } + } +} diff --git a/launcher/qml/Components/GameOptionsTab.qml b/launcher/qml/Components/GameOptionsTab.qml new file mode 100644 index 00000000..fca2fa78 --- /dev/null +++ b/launcher/qml/Components/GameOptionsTab.qml @@ -0,0 +1,98 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import MeshMC.Theme + +/* + * This instance's options.txt, read-only - the widget-free replacement for + * GameOptionsPage, which is read-only too (its own model has a save(), but + * nothing on that page ever calls it - see its header comment). + */ +Item { + id: root + + // KeyValueFilterModel over the instance's GameOptions (InstanceDetails.gameOptions). + property var model: null + + ColumnLayout { + anchors.fill: parent + spacing: Theme.space.md + + RowLayout { + Layout.fillWidth: true + spacing: Theme.space.sm + + SearchBox { + Layout.fillWidth: true + placeholderText: qsTr("Search options") + onTextChanged: if (root.model) root.model.filterText = text + } + Text { + visible: !!root.model + text: qsTr("%1 options").arg(list.count) + color: Theme.palette.textTertiary + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + } + } + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + radius: Theme.radius.lg + color: Theme.palette.surfaceSunken + border.width: 1 + border.color: Theme.palette.border + + ListView { + id: list + anchors.fill: parent + anchors.margins: Theme.space.sm + clip: true + model: root.model + boundsBehavior: Flickable.StopAtBounds + ScrollBar.vertical: ScrollBar {} + + delegate: RowLayout { + required property string key + required property string value + + width: list.width + height: Theme.control.height + spacing: Theme.space.md + + Text { + Layout.preferredWidth: list.width * 0.5 + text: key + elide: Text.ElideRight + color: Theme.palette.textPrimary + font.family: Theme.font.mono + font.pixelSize: Theme.type.label.pixelSize + } + Text { + Layout.fillWidth: true + text: value + elide: Text.ElideRight + color: Theme.palette.textSecondary + font.family: Theme.font.mono + font.pixelSize: Theme.type.label.pixelSize + } + } + } + + EmptyState { + anchors.centerIn: parent + visible: !root.model || list.count === 0 + title: qsTr("No options found") + body: root.model && root.model.filterText.length > 0 + ? qsTr("Nothing matches “%1”.").arg(root.model.filterText) + : qsTr("This instance has no options.txt yet - launch it once to create one.") + MeshIcon { iconName: "settings"; size: 40; color: Theme.palette.textTertiary } + } + } + } +} diff --git a/launcher/qml/Components/HomeJumpCard.qml b/launcher/qml/Components/HomeJumpCard.qml new file mode 100644 index 00000000..7bfa799e --- /dev/null +++ b/launcher/qml/Components/HomeJumpCard.qml @@ -0,0 +1,164 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import MeshMC.Theme + +/* + * One "Jump back in" card on the Home page: a recently played instance, + * wide enough for its own cover art plus a line of facts. Used directly as + * a Repeater delegate against the shell's recentModel, so the required + * properties are filled from InstanceList's named roles the same way + * InstanceCard's are. + * + * Play here is a plain neutral button, not the accent-filled PlayButton + * used elsewhere: the Home page's one accent-filled control is the + * persistent play bar's own Play (design-plan.md Principle 1). Clicking the + * card body itself opens the instance page instead of playing it. + */ +Item { + id: root + + required property string instanceId + required property string name + required property string iconKey + required property bool isRunning + required property bool canLaunch + required property var lastLaunch + required property var totalTimePlayed + required property string gameVersion + required property string loader + required property color iconTint + // Newest screenshot url (InstanceList's coverImage role), or "" -- same + // source as InstanceCard's cover; CoverArt falls back to a tinted plate. + required property string coverImage + // Set by LaunchTask around the game process exit -- a calm reminder, + // not an alarm. + required property bool hasCrashed + + signal clicked() + signal playRequested() + signal stopRequested() + + readonly property bool hasPhoto: root.coverImage.length > 0 + readonly property bool hovered: hoverHandler.hovered + + implicitWidth: 336 + implicitHeight: 148 + + activeFocusOnTab: true + Keys.onReturnPressed: root.clicked() + + Accessible.role: Accessible.ListItem + Accessible.name: root.name + Accessible.description: Format.versionLine(root.loader, root.gameVersion) + + HoverHandler { id: hoverHandler } + + Rectangle { + id: card + anchors.fill: parent + radius: Theme.radius.lg + color: root.hovered ? Theme.palette.surfaceRaised : Theme.palette.surface + border.width: 1 + border.color: root.hovered ? Theme.palette.borderStrong : Theme.palette.border + clip: true + + Behavior on color { ColorAnimation { duration: Theme.motion.fast; easing.type: Theme.motion.easing } } + Behavior on border.color { ColorAnimation { duration: Theme.motion.fast; easing.type: Theme.motion.easing } } + + // Declared before the cover/content below, same order InstanceCard + // uses, so the neutral Play button (a real child control further + // down) still gets first refusal on a press inside its own bounds. + TapHandler { + acceptedButtons: Qt.LeftButton + gesturePolicy: TapHandler.ReleaseWithinBounds + onTapped: { root.forceActiveFocus(); root.clicked() } + } + + CoverArt { + id: art + anchors.fill: parent + radius: card.radius + source: root.coverImage + tint: root.iconTint + iconKey: root.iconKey + iconSize: 48 + seed: root.instanceId + scrim: "horizontal" + hovered: root.hovered + matte: card.color + } + + Column { + id: content + anchors.left: parent.left + anchors.leftMargin: Theme.space.lg + anchors.right: playButton.left + anchors.rightMargin: Theme.space.md + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.space.xs + + Text { + width: parent.width + text: root.name + elide: Text.ElideRight + color: root.hasPhoto ? Theme.media.text : Theme.palette.textPrimary + font.family: Theme.font.family + font.pixelSize: Theme.type.bodyStrong.pixelSize + 1 + font.weight: Font.Bold + } + + Row { + spacing: Theme.space.xs + + Tag { + text: Format.versionLine(root.loader, root.gameVersion) + iconName: "layers" + onMedia: root.hasPhoto + } + Tag { + readonly property string played: Format.playTime(root.totalTimePlayed) + text: played.length > 0 ? qsTr("%1 · %2").arg(Format.lastPlayed(root.lastLaunch)).arg(played) + : Format.lastPlayed(root.lastLaunch) + iconName: "clock" + onMedia: root.hasPhoto + } + } + + StatusBadge { + visible: root.hasCrashed + tone: "danger" + text: qsTr("Crashed last time") + } + } + + Button { + id: playButton + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: Theme.space.md + text: root.isRunning ? qsTr("Stop") : qsTr("Play") + icon.source: Icons.url(root.isRunning ? "stop" : "play") + enabled: root.isRunning || root.canLaunch + focusPolicy: Qt.NoFocus + onClicked: root.isRunning ? root.stopRequested() : root.playRequested() + } + } + + Rectangle { + // Keyboard focus ring, outset so it never competes with the card's + // own border -- same idiom InstanceCard uses. + x: card.x - 3 + y: card.y - 3 + width: card.width + 6 + height: card.height + 6 + radius: card.radius + 3 + color: "transparent" + border.width: 2 + border.color: Theme.palette.focusRing + visible: root.activeFocus + } +} diff --git a/launcher/qml/Components/HomePage.qml b/launcher/qml/Components/HomePage.qml new file mode 100644 index 00000000..389f41f9 --- /dev/null +++ b/launcher/qml/Components/HomePage.qml @@ -0,0 +1,373 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import MeshMC.Theme + +/* + * The Home page: one purpose, "get back to what I was doing". The default + * start page and the sidebar's first item. A welcome line, the most + * recently played instances ("Jump back in"), the worlds played most + * recently across all of them ("Recent worlds"), and a slim strip of the + * whole library -- all from models the shell already keeps locally + * (recentModel/recentWorlds), so opening this page makes no network call. + * + * The page's own header (title + profile button) is TopBar, in Main.qml, + * the same as every other page -- this file only owns the content below it. + */ +Item { + id: root + + /* + * The identical look every section heading on this page shares ("Jump + * back in", "Recent worlds", "Your library") -- extracted once the + * third copy made it a repeat rather than a one-off (project DRY rule). + */ + component SectionHeading: Text { + color: Theme.palette.textPrimary + font.family: Theme.font.family + font.pixelSize: Theme.type.title.pixelSize + 1 + font.weight: Font.Bold + } + + // Most recently played instances first; never-played ones left out + // (QmlShell.recentModel) -- "Jump back in" takes its first three. + property var recentModel: null + // Every instance, for "Your library" and the empty-library check. + property var instanceModel: null + // QmlShell.recentWorlds -- most recently played worlds across every + // instance; see RecentWorldsModel's own class comment. + property var recentWorldsModel: null + property string accountName: "" + + signal launchRequested(string id) + signal stopRequested(string id) + // Opens the instance page on its Overview tab. + signal openInstanceRequested(string id) + // Opens the instance page on its Worlds tab -- a Recent worlds tile. + signal openInstanceWorldsRequested(string id) + signal createRequested() + signal discoverRequested() + signal libraryRequested() + + readonly property int pagePadding: Theme.space.xl + Theme.space.xs + readonly property int libraryCount: root.instanceModel && root.instanceModel.count !== undefined ? root.instanceModel.count : 0 + readonly property int recentCount: root.recentModel && root.recentModel.count !== undefined ? root.recentModel.count : 0 + readonly property bool hasLibrary: root.libraryCount > 0 + + // -- Backdrop: the most recently played instance's own cover art ------- + // Bounded to the page's own top region (design-plan.md §5's "visible + // tonal variation... within the first ~320px"), behind "Welcome back"/ + // "Jump back in" -- recentModel is already most-recent-first, so the + // one this page needs is just its own first row. + // Always visible, even with zero recents: a real screenshot wins when + // there is one, otherwise `wideHero` draws Home's own pixel-art + // panorama rather than nothing (or, previously, a per-instance plate + // stretched across a much wider band than it was designed for). + PageBackdrop { + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + height: 320 + fadeBottom: true + wideHero: true + cover: firstRecent.coverImage + tint: firstRecent.iconTint + } + + // A zero-size probe on recentModel's row 0 -- already most-recent-first, + // so no reduction like LibraryPage's backdrop probe is needed here. + Repeater { + model: root.recentModel + delegate: Item { + required property int index + required property string coverImage + required property color iconTint + visible: false + width: 0 + height: 0 + function sync() { + if (index === 0) { + firstRecent.coverImage = coverImage + firstRecent.iconTint = iconTint + } + } + onIndexChanged: sync() + onCoverImageChanged: sync() + onIconTintChanged: sync() + Component.onCompleted: sync() + } + } + + QtObject { + id: firstRecent + property string coverImage: "" + property color iconTint: Theme.palette.textTertiary + } + + Flickable { + id: flick + anchors.fill: parent + visible: root.hasLibrary + contentWidth: width + contentHeight: content.y + content.height + Theme.space.xxl + boundsBehavior: Flickable.StopAtBounds + clip: true + + ScrollBar.vertical: ScrollBar {} + + Column { + id: content + x: root.pagePadding + y: Theme.space.md + width: flick.width - root.pagePadding * 2 + spacing: Theme.space.xl + Theme.space.sm + + Text { + width: parent.width + text: root.accountName.length > 0 ? qsTr("Welcome back, %1").arg(root.accountName) + : qsTr("Welcome back") + elide: Text.ElideRight + color: Theme.palette.textPrimary + font.family: Theme.font.family + font.pixelSize: Theme.type.heading.pixelSize + font.weight: Theme.type.heading.weight + } + + // -- Jump back in ---------------------------------------------- + Column { + width: parent.width + spacing: Theme.space.md + + SectionHeading { + visible: root.recentCount > 0 + text: qsTr("Jump back in") + } + + // A horizontal strip rather than a grid: at narrow widths it + // scrolls sideways instead of wrapping the cards onto a + // second row. + Flickable { + visible: root.recentCount > 0 + // Bled by the page's own right padding rather than + // stopping flush at the content column's edge: with + // width: parent.width alone, whenever the 3rd card + // almost-but-not-quite fits it clips with a hard edge + // and zero visible hint that it exists -- the + // horizontal ScrollBar stays invisible until hovered, + // and this is the only sideways-scrolling row in the + // app, so there is no other affordance to notice by. + // root.pagePadding of page gutter is otherwise idle + // whitespace here, so reclaiming it costs nothing and + // guarantees a real peek of the next card (see review + // finding on this row). + width: parent.width + root.pagePadding + height: jumpRow.height + contentWidth: jumpRow.width + contentHeight: height + flickableDirection: Flickable.HorizontalFlick + boundsBehavior: Flickable.StopAtBounds + clip: true + + ScrollBar.horizontal: ScrollBar {} + + Row { + id: jumpRow + spacing: Theme.space.lg + + Repeater { + model: root.recentModel + // A plain Item wrapper, not HomeJumpCard directly: + // HomeJumpCard's own required properties put a + // direct delegate in "bound" mode, where a bare + // `index` reference no longer resolves for + // Repeater. The wrapper carries no required + // properties of its own, so it stays "unbound", + // and `model.` -- always valid regardless + // of binding mode -- forwards each role into + // HomeJumpCard explicitly. + delegate: Item { + id: jumpDelegate + // Only the three most recently played, most + // recent first -- recentModel already sorts + // that way. + visible: model.index < 3 + width: visible ? card.width : 0 + height: visible ? card.height : 0 + + HomeJumpCard { + id: card + instanceId: model.instanceId + name: model.name + iconKey: model.iconKey + isRunning: model.isRunning + canLaunch: model.canLaunch + lastLaunch: model.lastLaunch + totalTimePlayed: model.totalTimePlayed + gameVersion: model.gameVersion + loader: model.loader + iconTint: model.iconTint + coverImage: model.coverImage + hasCrashed: model.hasCrashed + onClicked: root.openInstanceRequested(model.instanceId) + onPlayRequested: root.launchRequested(model.instanceId) + onStopRequested: root.stopRequested(model.instanceId) + } + } + } + } + } + + // The library has instances but none has ever been played: + // never dress one of them up as "continue playing". + Row { + visible: root.recentCount === 0 + spacing: Theme.space.sm + + MeshIcon { + anchors.verticalCenter: parent.verticalCenter + iconName: "compass" + size: Theme.icon.md + color: Theme.palette.textTertiary + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: qsTr("New here? Start with Discover.") + color: Theme.palette.textSecondary + font.family: Theme.font.family + font.pixelSize: Theme.type.body.pixelSize + } + Button { + anchors.verticalCenter: parent.verticalCenter + flat: true + text: qsTr("Discover") + onClicked: root.discoverRequested() + } + } + } + + // -- Recent worlds ----------------------------------------------- + // Hidden entirely when there is nothing to show -- no empty + // section, no placeholder. + Column { + width: parent.width + spacing: Theme.space.md + visible: worldsRepeater.count > 0 + + SectionHeading { + text: qsTr("Recent worlds") + } + + Flickable { + width: parent.width + height: worldsRow.height + contentWidth: worldsRow.width + contentHeight: height + flickableDirection: Flickable.HorizontalFlick + boundsBehavior: Flickable.StopAtBounds + clip: true + + ScrollBar.horizontal: ScrollBar {} + + Row { + id: worldsRow + spacing: Theme.space.sm + + Repeater { + id: worldsRepeater + model: root.recentWorldsModel + delegate: HomeWorldTile { + onClicked: root.openInstanceWorldsRequested(instanceId) + } + } + } + } + } + + // -- Your library ------------------------------------------------- + Column { + width: parent.width + spacing: Theme.space.md + + Item { + width: parent.width + height: Math.max(libraryHeading.implicitHeight, seeAllButton.implicitHeight) + + SectionHeading { + id: libraryHeading + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: qsTr("Your library") + } + Button { + id: seeAllButton + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + flat: true + text: qsTr("See all") + onClicked: root.libraryRequested() + } + } + + Flickable { + width: parent.width + height: libraryRow.height + contentWidth: libraryRow.width + contentHeight: height + flickableDirection: Flickable.HorizontalFlick + boundsBehavior: Flickable.StopAtBounds + clip: true + + ScrollBar.horizontal: ScrollBar {} + + Row { + id: libraryRow + spacing: Theme.space.xs + + Repeater { + model: root.instanceModel + delegate: RecentItem { + // RecentItem's own implicitWidth (200) is + // sized for its expanded row form and does + // not shrink for `compact` -- only its + // internal square does. Matching that square + // here keeps the strip tight instead of + // spacing icons 200px apart. + width: 40 + compact: true + onClicked: root.openInstanceRequested(instanceId) + } + } + } + } + } + + // Calm, empty breathing room -- reserved for a future roaming + // 3D cat. Nothing is drawn here on purpose. + Item { + width: 1 + height: 160 + } + } + } + + EmptyState { + anchors.centerIn: parent + visible: !root.hasLibrary + title: qsTr("Your library is empty") + body: qsTr("An instance is one Minecraft setup: a version, a mod loader and its mods. Create one to start playing.") + actionText: qsTr("Create your first instance") + actionIcon: "plus" + onActionTriggered: root.createRequested() + + Image { + source: PixelArt.emptyUrl("no_instances") + sourceSize: Qt.size(64, 64) + width: 64 + height: 64 + smooth: false + } + } +} diff --git a/launcher/qml/Components/HomeWorldTile.qml b/launcher/qml/Components/HomeWorldTile.qml new file mode 100644 index 00000000..1479e75d --- /dev/null +++ b/launcher/qml/Components/HomeWorldTile.qml @@ -0,0 +1,127 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import MeshMC.Theme + +/* + * One tile in the Home page's "Recent worlds" row: a world's own icon (or, + * lacking one, one of PixelArt's 16x16 block textures), the world's name, + * which instance it belongs to, and when it was last played. Used directly + * as a Repeater delegate against the shell's recentWorlds model, so the + * required properties are filled from RecentWorldsModel's roles. + */ +AbstractButton { + id: control + + required property string worldName + required property string folderName + required property string iconUrl + required property var lastPlayed + required property string instanceId + required property string instanceName + + readonly property bool hasIcon: control.iconUrl.length > 0 + + implicitWidth: 176 + implicitHeight: 72 + hoverEnabled: true + + Accessible.name: qsTr("%1, in %2").arg(control.worldName).arg(control.instanceName) + + ToolTip.visible: control.hovered + ToolTip.delay: 500 + ToolTip.text: qsTr("%1 — %2").arg(control.worldName).arg(control.instanceName) + + background: Rectangle { + radius: Theme.radius.md + color: control.down ? Theme.palette.pressedOverlay + : control.hovered ? Theme.palette.hoverOverlay : "transparent" + border.width: 1 + border.color: Theme.palette.border + Behavior on color { ColorAnimation { duration: Theme.motion.fast; easing.type: Theme.motion.easing } } + } + + contentItem: Item { + Rectangle { + id: tile + anchors.left: parent.left + anchors.leftMargin: Theme.space.sm + anchors.verticalCenter: parent.verticalCenter + // 48 = three whole pixels per texel of a 16x16 block texture, + // so the fallback below stays crisp rather than unevenly scaled. + width: 48 + height: 48 + radius: Theme.radius.sm + border.width: 1 + border.color: Theme.palette.border + clip: true + + Image { + anchors.fill: parent + visible: control.hasIcon + source: control.hasIcon ? control.iconUrl : "" + sourceSize: Qt.size(width, height) + fillMode: Image.PreserveAspectCrop + smooth: false + asynchronous: true + } + + // No world icon: one of PixelArt's block textures, picked + // deterministically by this world's own folder name -- a + // bounded, designed fallback in place of the old flat + // surfaceOverlay square (design-plan.md G3). A whole 96x54 + // landscape scene would only be an unreadable smear at this + // size; a single block reads at a glance. No sourceSize: the + // texture is 16x16 and must scale by nearest neighbour, not be + // pre-filtered. + Image { + anchors.fill: parent + visible: !control.hasIcon + source: control.hasIcon ? "" : PixelArt.blockUrlFor(control.folderName) + fillMode: Image.Stretch + smooth: false + asynchronous: true + } + } + + Column { + anchors.left: tile.right + anchors.leftMargin: Theme.space.sm + anchors.right: parent.right + anchors.rightMargin: Theme.space.sm + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.space.xxs + + Text { + width: parent.width + text: control.worldName + elide: Text.ElideRight + color: Theme.palette.textPrimary + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + font.weight: Theme.type.label.weight + } + + Text { + width: parent.width + text: control.instanceName + elide: Text.ElideRight + color: Theme.palette.textSecondary + font.family: Theme.font.family + font.pixelSize: Theme.type.caption.pixelSize + } + + Text { + width: parent.width + text: Format.lastPlayed(control.lastPlayed) + elide: Text.ElideRight + color: Theme.palette.textTertiary + font.family: Theme.font.family + font.pixelSize: Theme.type.caption.pixelSize + } + } + } +} diff --git a/launcher/qml/Components/IconButton.qml b/launcher/qml/Components/IconButton.qml new file mode 100644 index 00000000..3566a745 --- /dev/null +++ b/launcher/qml/Components/IconButton.qml @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import MeshMC.Theme + +/* + * A square, quiet button that is only an icon, with its label as a tooltip + * and as the accessible name -- an icon alone says nothing to a screen + * reader. + */ +Button { + id: control + + property string iconName + property string tip + property int size: Theme.control.height + + flat: true + display: AbstractButton.IconOnly + icon.source: iconName.length > 0 ? Icons.url(iconName) : "" + icon.width: Theme.icon.sm + 2 + icon.height: Theme.icon.sm + 2 + implicitWidth: size + implicitHeight: size + padding: 0 + + Accessible.name: tip + + ToolTip.visible: tip.length > 0 && hovered + ToolTip.delay: 500 + ToolTip.text: tip +} diff --git a/launcher/qml/Components/IconPickerDialog.qml b/launcher/qml/Components/IconPickerDialog.qml new file mode 100644 index 00000000..75664b74 --- /dev/null +++ b/launcher/qml/Components/IconPickerDialog.qml @@ -0,0 +1,115 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import MeshMC.Theme + +/* + * Pick an instance icon from every icon MeshMC knows -- the built-in set + * and whatever is in the icons folder. New icons are added by dropping + * images into that folder; the grid picks them up as they arrive. + */ +Dialog { + id: root + + // IconList (roles include `key` and `name`). + property var iconsModel: null + property string current + signal picked(string key) + signal openFolderRequested() + + parent: Overlay.overlay + anchors.centerIn: parent + width: Math.min(640, parent ? parent.width - Theme.space.xxl * 2 : 640) + height: Math.min(520, parent ? parent.height - Theme.space.xxl * 2 : 520) + modal: true + title: qsTr("Choose an icon") + + header: DialogHeader { + title: root.title + icon: "image" + } + + contentItem: GridView { + id: grid + clip: true + boundsBehavior: Flickable.StopAtBounds + model: root.iconsModel + cellWidth: 88 + cellHeight: 96 + ScrollBar.vertical: ScrollBar {} + + delegate: AbstractButton { + id: cell + required property string key + required property string name + readonly property bool selected: key === root.current + + width: grid.cellWidth + height: grid.cellHeight + hoverEnabled: true + Accessible.name: name + onClicked: root.current = key + onDoubleClicked: { root.picked(key); root.close() } + + background: Rectangle { + anchors.fill: parent + anchors.margins: 4 + radius: Theme.radius.md + color: cell.selected ? Theme.palette.accentSubtle + : cell.hovered ? Theme.palette.hoverOverlay : "transparent" + border.width: cell.selected ? 2 : 0 + border.color: Theme.palette.accent + } + + contentItem: Column { + spacing: Theme.space.xs + topPadding: Theme.space.sm + Image { + anchors.horizontalCenter: parent.horizontalCenter + width: 48 + height: 48 + source: "image://instanceicon/" + cell.key + sourceSize: Qt.size(48, 48) + fillMode: Image.PreserveAspectFit + } + Text { + width: cell.width - Theme.space.sm * 2 + anchors.horizontalCenter: parent.horizontalCenter + horizontalAlignment: Text.AlignHCenter + text: cell.name + elide: Text.ElideRight + color: cell.selected ? Theme.palette.textPrimary : Theme.palette.textTertiary + font.family: Theme.font.family + font.pixelSize: Theme.type.caption.pixelSize + } + } + } + } + + footer: Row { + spacing: Theme.space.sm + padding: Theme.space.lg + topPadding: Theme.space.sm + layoutDirection: Qt.RightToLeft + + Button { + text: qsTr("Use icon") + highlighted: true + onClicked: { root.picked(root.current); root.close() } + } + Button { + text: qsTr("Cancel") + flat: true + onClicked: root.close() + } + Button { + flat: true + text: qsTr("Open icons folder") + icon.source: Icons.url("folder") + onClicked: root.openFolderRequested() + } + } +} diff --git a/launcher/qml/Components/Icons.qml b/launcher/qml/Components/Icons.qml new file mode 100644 index 00000000..b22355bd --- /dev/null +++ b/launcher/qml/Components/Icons.qml @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +pragma Singleton +import QtQuick + +/* + * Icon source lookup for code that wants a plain url rather than a MeshIcon + * instance -- e.g. an IconLabel/Button icon.source binding. The qrc path is + * spelled out in full (rather than Qt.resolvedUrl, which MeshIcon uses) since + * a singleton has no per-instance "own file" to resolve relative to. + */ +QtObject { + function url(name) { + return "qrc:/qt/qml/MeshMC/Components/icons/" + name + ".svg" + } +} diff --git a/launcher/qml/Components/InstanceCard.qml b/launcher/qml/Components/InstanceCard.qml new file mode 100644 index 00000000..93253b4b --- /dev/null +++ b/launcher/qml/Components/InstanceCard.qml @@ -0,0 +1,303 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import MeshMC.Theme + +/* + * One instance in the library grid: a cover tinted from the instance's own + * icon, the name, and what it runs. Used directly as a delegate -- the + * required properties are filled from InstanceList's named roles, so this + * file never mentions role numbers. + * + * Play lives on the cover and appears on hover (always while running, as + * Stop); click selects, double-click plays, right-click or the "more" + * button asks the page for the instance menu. + */ +Item { + id: root + + required property string instanceId + required property string name + required property string iconKey + required property bool isRunning + required property bool canLaunch + required property var lastLaunch + required property string gameVersion + required property string loader + required property color iconTint + // Newest screenshot url (InstanceList's coverImage role), or "" when + // the instance has none yet -- the cover's own art, see CoverArt.qml. + required property string coverImage + // Empty while nothing is being launched; progress is 0..1, or negative + // while the launch cannot say how far along it is. + required property string launchStatus + required property real launchProgress + + property bool selected: false + // Lets a static review page (Gallery.qml) show the hover state. + property bool forceHovered: false + + signal clicked() + signal doubleClicked() + signal playRequested() + signal stopRequested() + signal menuRequested() + + readonly property bool hovered: forceHovered || hoverHandler.hovered + readonly property bool launching: launchStatus.length > 0 + readonly property int inset: Theme.space.sm - 2 + readonly property int coverHeight: Math.round((width - inset * 2) * 0.6) + readonly property int coverRadius: Theme.radius.md + 2 + + implicitWidth: 208 + implicitHeight: inset + coverHeight + Theme.space.md + Theme.type.bodyStrong.lineHeightPx + + Theme.space.xxs + Theme.type.caption.lineHeightPx + Theme.space.md + + activeFocusOnTab: true + Keys.onReturnPressed: root.playRequested() + Keys.onSpacePressed: root.clicked() + Keys.onMenuPressed: root.menuRequested() + + Accessible.role: Accessible.ListItem + Accessible.name: root.name + Accessible.description: Format.versionLine(root.loader, root.gameVersion) + + HoverHandler { id: hoverHandler } + + Rectangle { + id: card + width: parent.width + height: parent.height + radius: Theme.radius.lg + color: root.hovered ? Theme.palette.surfaceRaised : Theme.palette.surface + border.width: root.selected ? 2 : 1 + border.color: root.selected ? Theme.palette.accent + : root.hovered ? Theme.palette.borderStrong : Theme.palette.border + + Behavior on color { ColorAnimation { duration: Theme.motion.fast; easing.type: Theme.motion.easing } } + Behavior on border.color { ColorAnimation { duration: Theme.motion.fast; easing.type: Theme.motion.easing } } + + // ReleaseWithinBounds grabs on press, so the page's "click empty + // space to deselect" handler underneath never sees a card click. + TapHandler { + acceptedButtons: Qt.LeftButton + gesturePolicy: TapHandler.ReleaseWithinBounds + onTapped: { root.forceActiveFocus(); root.clicked() } + onDoubleTapped: root.playRequested() + } + TapHandler { + acceptedButtons: Qt.RightButton + gesturePolicy: TapHandler.ReleaseWithinBounds + onTapped: { root.forceActiveFocus(); root.clicked(); root.menuRequested() } + } + + Item { + id: cover + x: root.inset + y: root.inset + width: parent.width - root.inset * 2 + height: root.coverHeight + + CoverArt { + id: art + anchors.fill: parent + radius: root.coverRadius + source: root.coverImage + tint: root.iconTint + iconKey: root.iconKey + seed: root.instanceId + iconSize: Math.max(48, Math.min(96, Math.round(root.coverHeight * 0.55))) + // Protects the running pill/more button up top and the + // play button/icon badge down below from a bright photo. + scrim: "bottom" + hovered: root.hovered + matte: card.color + } + + // While launching the cover dims and a bar runs along its foot. + Rectangle { + anchors.fill: parent + radius: root.coverRadius + color: Qt.rgba(0, 0, 0, 0.45) + opacity: root.launching ? 1 : 0 + visible: opacity > 0 + Behavior on opacity { NumberAnimation { duration: Theme.motion.normal } } + } + + LaunchProgressBar { + visible: root.launching + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: Theme.space.sm + onMedia: true + progress: root.launchProgress + } + + // Running: a pill in the corner, readable on any tint. + Rectangle { + visible: root.isRunning + anchors.left: parent.left + anchors.top: parent.top + anchors.margins: Theme.space.sm + radius: height / 2 + height: Theme.control.heightSm - 6 + width: runningRow.implicitWidth + Theme.space.sm * 2 + color: Qt.rgba(0, 0, 0, 0.55) + + Row { + id: runningRow + anchors.centerIn: parent + spacing: Theme.space.xs + 1 + Rectangle { + anchors.verticalCenter: parent.verticalCenter + // A perfect circle, like Switch/Slider's own round + // parts: computed half-width, not a radius token. + width: 7; height: 7; radius: width / 2 + color: Theme.palette.success + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: qsTr("Running") + color: "white" + font.family: Theme.font.family + font.pixelSize: Theme.type.caption.pixelSize - 1 + font.weight: Font.DemiBold + } + } + } + + IconButton { + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Theme.space.xs + size: Theme.control.heightSm + iconName: "more" + tip: qsTr("More") + opacity: root.hovered ? 1 : 0 + visible: opacity > 0 + focusPolicy: Qt.NoFocus + Behavior on opacity { NumberAnimation { duration: Theme.motion.fast } } + onClicked: root.menuRequested() + } + + PlayButton { + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: Theme.space.sm + running: root.isRunning + enabled: root.isRunning || root.canLaunch + // A plain fade, no overshoot scale-in (design-plan.md §4/§2.4). + opacity: !root.launching && (root.hovered || root.isRunning) ? 1 : 0 + visible: opacity > 0 + focusPolicy: Qt.NoFocus + Behavior on opacity { NumberAnimation { duration: Theme.motion.fast } } + onClicked: root.isRunning ? root.stopRequested() : root.playRequested() + } + } + + // A small badge for the instance icon, peeking over the cover's + // bottom-left edge -- only shown once there is a real screenshot + // to badge; the fallback cover already *is* the icon, large and + // centred, and a second copy of it would just be clutter. + Item { + id: iconBadge + readonly property int size: 36 + visible: art.hasPhoto + x: cover.x + Theme.space.sm + y: cover.y + cover.height - size * 0.8 + width: size + height: size + + Rectangle { + anchors.fill: parent + anchors.margins: -2 + radius: Theme.radius.md + 2 + color: Qt.rgba(0, 0, 0, Theme.dark ? 0.4 : 0.18) + } + + Rectangle { + anchors.fill: parent + radius: Theme.radius.md + color: Theme.palette.surface + border.width: 1 + border.color: Theme.palette.border + + Image { + anchors.fill: parent + anchors.margins: 5 + source: root.iconKey.length > 0 ? "image://instanceicon/" + root.iconKey : "" + sourceSize: Qt.size(width, height) + fillMode: Image.PreserveAspectFit + smooth: false + } + } + } + + Text { + id: title + anchors.top: cover.bottom + anchors.topMargin: Theme.space.md - 2 + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: Theme.space.md + anchors.rightMargin: Theme.space.md + text: root.name + elide: Text.ElideRight + color: Theme.palette.textPrimary + font.family: Theme.font.family + font.pixelSize: Theme.type.bodyStrong.pixelSize + font.weight: Theme.type.bodyStrong.weight + } + + Item { + anchors.top: title.bottom + anchors.topMargin: Theme.space.xxs + anchors.left: title.left + anchors.right: title.right + height: Theme.type.caption.lineHeightPx + + Text { + id: versionText + anchors.left: parent.left + anchors.right: timeText.left + anchors.rightMargin: Theme.space.sm + anchors.verticalCenter: parent.verticalCenter + text: root.launching ? root.launchStatus : Format.versionLine(root.loader, root.gameVersion) + elide: Text.ElideRight + color: root.launching ? Theme.palette.accent : Theme.palette.textSecondary + font.family: Theme.font.family + font.pixelSize: Theme.type.caption.pixelSize + font.weight: Font.Medium + } + + Text { + id: timeText + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: root.launching ? (root.launchProgress >= 0 ? Math.round(root.launchProgress * 100) + "%" : "") + : Format.lastPlayed(root.lastLaunch) + color: root.launching ? Theme.palette.accent : Theme.palette.textTertiary + font.family: Theme.font.family + font.pixelSize: Theme.type.caption.pixelSize + } + } + } + + Rectangle { + // Keyboard focus ring, outset so it never competes with the + // selected border. + x: card.x - 3 + y: card.y - 3 + width: card.width + 6 + height: card.height + 6 + radius: card.radius + 3 + color: "transparent" + border.width: 2 + border.color: Theme.palette.focusRing + visible: root.activeFocus && !root.selected + } +} diff --git a/launcher/qml/Components/InstanceListRow.qml b/launcher/qml/Components/InstanceListRow.qml new file mode 100644 index 00000000..601c5405 --- /dev/null +++ b/launcher/qml/Components/InstanceListRow.qml @@ -0,0 +1,234 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import MeshMC.Theme + +/* + * One instance in the library's list view: a dense row instead of + * InstanceCard's cover art, for a library that would rather scan names than + * browse thumbnails. Same roles and signals as InstanceCard (it is bound to + * the same per-group model), so the two delegates are interchangeable from + * InstanceSection's point of view -- only the layout differs. + * + * The trailing columns drop out at narrow widths rather than truncating into + * nonsense; the name always keeps the room it needs to stay readable. + */ +Item { + id: root + + required property string instanceId + required property string name + required property string iconKey + required property bool isRunning + required property bool canLaunch + required property var lastLaunch + required property var totalTimePlayed + required property string gameVersion + required property string loader + required property color iconTint + required property string launchStatus + required property real launchProgress + + property bool selected: false + property bool forceHovered: false + + signal clicked() + signal doubleClicked() + signal playRequested() + signal stopRequested() + signal menuRequested() + + readonly property bool hovered: forceHovered || hoverHandler.hovered + readonly property bool launching: launchStatus.length > 0 + readonly property int iconExtent: Theme.control.height + readonly property bool showVersionColumn: width > 460 + readonly property bool showStatsColumns: width > 620 + + implicitWidth: 400 + implicitHeight: iconExtent + Theme.space.sm * 2 + + activeFocusOnTab: true + Keys.onReturnPressed: root.playRequested() + Keys.onSpacePressed: root.clicked() + Keys.onMenuPressed: root.menuRequested() + + Accessible.role: Accessible.ListItem + Accessible.name: root.name + Accessible.description: Format.versionLine(root.loader, root.gameVersion) + + HoverHandler { id: hoverHandler } + + Rectangle { + id: surface + anchors.fill: parent + radius: Theme.radius.md + color: root.selected ? Theme.palette.accentSubtle + : root.hovered ? Theme.palette.surfaceRaised : "transparent" + border.width: root.selected ? 1 : 0 + border.color: Theme.palette.accent + + Behavior on color { ColorAnimation { duration: Theme.motion.fast; easing.type: Theme.motion.easing } } + + TapHandler { + acceptedButtons: Qt.LeftButton + gesturePolicy: TapHandler.ReleaseWithinBounds + onTapped: { root.forceActiveFocus(); root.clicked() } + onDoubleTapped: root.playRequested() + } + TapHandler { + acceptedButtons: Qt.RightButton + gesturePolicy: TapHandler.ReleaseWithinBounds + onTapped: { root.forceActiveFocus(); root.clicked(); root.menuRequested() } + } + + // The accent bar a selected InstanceCard shows as a border reads as + // a stripe here instead -- a full card border on a thin row would + // squeeze the content next to it. + Rectangle { + visible: root.selected + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.margins: 1 + width: 3 + radius: Theme.radius.xs + color: Theme.palette.accent + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: Theme.space.md + anchors.rightMargin: Theme.space.sm + spacing: Theme.space.md + + Item { + Layout.preferredWidth: root.iconExtent + Layout.preferredHeight: root.iconExtent + + Image { + id: icon + anchors.fill: parent + source: root.iconKey.length > 0 ? "image://instanceicon/" + root.iconKey : "" + sourceSize: Qt.size(width, height) + fillMode: Image.PreserveAspectFit + } + + // Pulsing while running, same treatment as the sidebar's + // Recent list -- one running-instance language everywhere. + Rectangle { + visible: root.isRunning + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: -1 + width: 9 + height: 9 + radius: width / 2 + color: Theme.palette.success + border.width: 2 + border.color: Theme.palette.canvas + + SequentialAnimation on opacity { + running: root.isRunning + loops: Animation.Infinite + NumberAnimation { from: 1.0; to: 0.45; duration: 900; easing.type: Easing.InOutSine } + NumberAnimation { from: 0.45; to: 1.0; duration: 900; easing.type: Easing.InOutSine } + } + } + } + + Text { + Layout.fillWidth: true + Layout.minimumWidth: 0 + text: root.name + elide: Text.ElideRight + color: Theme.palette.textPrimary + font.family: Theme.font.family + font.pixelSize: Theme.type.bodyStrong.pixelSize + font.weight: Theme.type.bodyStrong.weight + } + + Text { + visible: root.showVersionColumn + Layout.preferredWidth: 150 + Layout.minimumWidth: 0 + text: root.launching ? root.launchStatus : Format.versionLine(root.loader, root.gameVersion) + elide: Text.ElideRight + color: root.launching ? Theme.palette.accent : Theme.palette.textSecondary + font.family: Theme.font.family + font.pixelSize: Theme.type.caption.pixelSize + font.weight: Font.Medium + } + + Text { + visible: root.showStatsColumns + Layout.preferredWidth: 92 + horizontalAlignment: Text.AlignRight + text: Format.lastPlayed(root.lastLaunch) + elide: Text.ElideRight + color: Theme.palette.textTertiary + font.family: Theme.font.family + font.pixelSize: Theme.type.caption.pixelSize + } + + Text { + visible: root.showStatsColumns + Layout.preferredWidth: 72 + horizontalAlignment: Text.AlignRight + text: Format.playTime(root.totalTimePlayed) + color: Theme.palette.textTertiary + font.family: Theme.font.family + font.pixelSize: Theme.type.caption.pixelSize + } + + Item { + Layout.preferredWidth: Theme.control.heightSm * 2 + Theme.space.xs + Layout.preferredHeight: root.iconExtent + + IconButton { + anchors.right: playButton.left + anchors.rightMargin: Theme.space.xs + anchors.verticalCenter: parent.verticalCenter + size: Theme.control.heightSm + iconName: "more" + tip: qsTr("More") + opacity: root.hovered ? 1 : 0 + visible: opacity > 0 + focusPolicy: Qt.NoFocus + Behavior on opacity { NumberAnimation { duration: Theme.motion.fast } } + onClicked: root.menuRequested() + } + + PlayButton { + id: playButton + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + size: Theme.control.heightSm + 4 + running: root.isRunning + enabled: root.isRunning || root.canLaunch + // A plain fade, no overshoot scale-in (design-plan.md §4/§2.4). + opacity: !root.launching && (root.hovered || root.isRunning) ? 1 : 0 + visible: opacity > 0 + focusPolicy: Qt.NoFocus + Behavior on opacity { NumberAnimation { duration: Theme.motion.fast } } + onClicked: root.isRunning ? root.stopRequested() : root.playRequested() + } + } + } + } + + Rectangle { + x: surface.x - 2 + y: surface.y - 2 + width: surface.width + 4 + height: surface.height + 4 + radius: surface.radius + 2 + color: "transparent" + border.width: 2 + border.color: Theme.palette.focusRing + visible: root.activeFocus + } +} diff --git a/launcher/qml/Components/InstanceOverviewTab.qml b/launcher/qml/Components/InstanceOverviewTab.qml new file mode 100644 index 00000000..fdcdb7f4 --- /dev/null +++ b/launcher/qml/Components/InstanceOverviewTab.qml @@ -0,0 +1,333 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import MeshMC.Theme + +/* + * The instance at a glance: a stat strip, a peek at its recent screenshots + * and mods, and the user's own notes -- each a short summary that hands off + * to the tab that has the full picture, rather than trying to be all of + * them at once. + * + * Not a SettingsScroll: that caps its column at 760px for reading comfort, + * which is right for a form but starves this dashboard's two-column layout + * and screenshot strip of the width they need. + */ +Flickable { + id: root + + property string loader + property string gameVersion + property var lastLaunch: 0 + property var totalTimePlayed: 0 + // InstanceDetails, for the screenshots/mods previews below. + property var details: null + property string notes + signal notesEdited(string text) + signal screenshotsRequested() + signal manageContentRequested() + + readonly property var shotsModel: root.details ? root.details.screenshots : null + readonly property var modsModel: root.details ? root.details.mods : null + + contentWidth: width + contentHeight: content.height + Theme.space.xl + boundsBehavior: Flickable.StopAtBounds + clip: true + Accessible.name: qsTr("Overview") + + ScrollBar.vertical: ScrollBar {} + + ColumnLayout { + id: content + x: Theme.space.xs + y: Theme.space.xs + width: root.width - Theme.space.xs * 2 + spacing: Theme.space.lg + + // Four facts worth knowing at a glance, wrapping to two columns (or + // one) rather than shrinking once the page gets narrower than a + // comfortable row of four. + Flow { + Layout.fillWidth: true + spacing: Theme.space.md + readonly property int perRow: width >= 640 ? 4 : width >= 340 ? 2 : 1 + readonly property real tileWidth: Math.floor((width - spacing * (perRow - 1)) / perRow) + + StatTile { + width: parent.tileWidth + iconName: "cube" + label: qsTr("Minecraft") + value: root.gameVersion + } + StatTile { + width: parent.tileWidth + iconName: "layers" + label: qsTr("Mod loader") + value: root.loader.length > 0 ? root.loader : qsTr("Vanilla") + } + StatTile { + width: parent.tileWidth + iconName: "play" + label: qsTr("Time played") + value: Format.playTime(root.totalTimePlayed) || qsTr("Not yet") + } + StatTile { + width: parent.tileWidth + iconName: "clock" + label: qsTr("Last played") + value: Format.lastPlayed(root.lastLaunch) + } + } + + // Screenshots and mods need real width to read well, and notes + // should not have to fight them for it -- two columns above ~1000px + // of content width, one below. + GridLayout { + id: board + Layout.fillWidth: true + columns: width >= 1000 ? 2 : 1 + columnSpacing: Theme.space.lg + rowSpacing: Theme.space.lg + + // Recent screenshots + Rectangle { + Layout.fillWidth: true + Layout.alignment: Qt.AlignTop + radius: Theme.radius.lg + color: Theme.palette.surface + border.width: 1 + border.color: Theme.palette.border + implicitHeight: shotsColumn.implicitHeight + Theme.space.lg * 2 + + ColumnLayout { + id: shotsColumn + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Theme.space.lg + spacing: Theme.space.md + + RowLayout { + Layout.fillWidth: true + spacing: Theme.space.sm + Text { + text: qsTr("Recent screenshots") + color: Theme.palette.textPrimary + font.family: Theme.font.family + font.pixelSize: Theme.type.title.pixelSize + font.weight: Theme.type.title.weight + } + Item { Layout.fillWidth: true } + Button { + flat: true + visible: !!root.shotsModel && root.shotsModel.count > 0 + text: qsTr("View all") + onClicked: root.screenshotsRequested() + } + } + + Row { + id: shotsRow + Layout.fillWidth: true + Layout.preferredHeight: tileHeight + spacing: Theme.space.sm + visible: !!root.shotsModel && root.shotsModel.count > 0 + + readonly property int tileWidth: Math.min(160, (width - spacing * 3) / 4) + readonly property int tileHeight: Math.round(tileWidth * 9 / 16) + + Repeater { + model: root.shotsModel + delegate: Rectangle { + id: shot + required property int index + required property string path + + visible: index < 4 + width: visible ? parent.tileWidth : 0 + height: parent.tileHeight + radius: Theme.radius.md + color: Theme.palette.surfaceSunken + border.width: 1 + border.color: shotHover.hovered ? Theme.palette.borderStrong : Theme.palette.border + clip: true + + HoverHandler { id: shotHover } + + Image { + anchors.fill: parent + anchors.margins: 1 + source: "image://screenshot/" + encodeURIComponent(shot.path) + sourceSize: Qt.size(256, 256) + fillMode: Image.PreserveAspectCrop + asynchronous: true + scale: shotHover.hovered ? 1.05 : 1.0 + Behavior on scale { NumberAnimation { duration: Theme.motion.normal; easing.type: Theme.motion.easing } } + } + + TapHandler { onTapped: root.screenshotsRequested() } + } + } + } + + Text { + Layout.fillWidth: true + visible: !root.shotsModel || root.shotsModel.count === 0 + text: qsTr("Press F2 in game; screenshots show up here.") + color: Theme.palette.textTertiary + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + } + } + } + + // Sidebar: a mods peek, and notes underneath it. + ColumnLayout { + Layout.fillWidth: board.columns === 1 + Layout.preferredWidth: board.columns === 2 ? 320 : -1 + Layout.alignment: Qt.AlignTop + spacing: Theme.space.lg + + Rectangle { + Layout.fillWidth: true + radius: Theme.radius.lg + color: Theme.palette.surface + border.width: 1 + border.color: Theme.palette.border + implicitHeight: modsColumn.implicitHeight + Theme.space.lg * 2 + + ColumnLayout { + id: modsColumn + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Theme.space.lg + spacing: Theme.space.sm + + RowLayout { + Layout.fillWidth: true + spacing: Theme.space.sm + Text { + text: qsTr("Mods") + color: Theme.palette.textPrimary + font.family: Theme.font.family + font.pixelSize: Theme.type.title.pixelSize + font.weight: Theme.type.title.weight + } + Item { Layout.fillWidth: true } + Button { + flat: true + visible: modsRepeater.count > 0 + text: qsTr("Manage") + onClicked: root.manageContentRequested() + } + } + + Column { + Layout.fillWidth: true + spacing: Theme.space.sm + visible: modsRepeater.count > 0 + + Repeater { + id: modsRepeater + model: root.modsModel + delegate: Row { + id: modRow + required property int index + required property var model + visible: index < 5 + width: parent.width + spacing: Theme.space.sm + + Rectangle { + anchors.verticalCenter: parent.verticalCenter + width: 6 + height: 6 + radius: 3 + color: modRow.model.enabled ? Theme.palette.success : Theme.palette.textDisabled + } + Text { + width: parent.width - 6 - parent.spacing + text: modRow.model.name + elide: Text.ElideRight + color: modRow.model.enabled ? Theme.palette.textPrimary : Theme.palette.textTertiary + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + } + } + } + } + + Text { + Layout.fillWidth: true + visible: modsRepeater.count === 0 + text: root.details && root.details.isMinecraft ? qsTr("No mods installed yet.") + : qsTr("This instance cannot have mods.") + color: Theme.palette.textTertiary + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + } + } + } + + Rectangle { + Layout.fillWidth: true + radius: Theme.radius.lg + color: Theme.palette.surface + border.width: 1 + border.color: Theme.palette.border + implicitHeight: notesColumn.implicitHeight + Theme.space.lg * 2 + + ColumnLayout { + id: notesColumn + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Theme.space.lg + spacing: Theme.space.sm + + Text { + text: qsTr("Notes") + color: Theme.palette.textPrimary + font.family: Theme.font.family + font.pixelSize: Theme.type.title.pixelSize + font.weight: Theme.type.title.weight + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 120 + radius: Theme.radius.md + color: Theme.palette.surfaceSunken + border.width: 1 + border.color: Theme.palette.border + + ScrollView { + anchors.fill: parent + anchors.margins: Theme.space.xs + + TextArea { + id: notesArea + placeholderText: qsTr("Seed, server address, which mods to update…") + wrapMode: TextEdit.Wrap + selectByMouse: true + background: null + color: Theme.palette.textPrimary + font.family: Theme.font.family + font.pixelSize: Theme.type.body.pixelSize + Component.onCompleted: text = root.notes + onActiveFocusChanged: if (!activeFocus && text !== root.notes) root.notesEdited(text) + } + } + } + } + } + } + } + } +} diff --git a/launcher/qml/Components/InstancePage.qml b/launcher/qml/Components/InstancePage.qml new file mode 100644 index 00000000..4831ce02 --- /dev/null +++ b/launcher/qml/Components/InstancePage.qml @@ -0,0 +1,271 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import MeshMC.Theme + +/* + * One instance, opened from the library: the same banner the library's old + * hero used -- folder, what it runs, the classic editor -- over tabs for + * what is inside it. Play/Stop no longer live on the banner; the persistent + * play bar plays and stops whatever instance this page has open instead. + */ +Item { + id: root + + // Single-row model of this instance (the same roles as the library). + property var headerModel: null + // InstanceDetails for it. + property var details: null + property int systemMemoryMiB: 8192 + property string tab: "overview" + // (row, versionId) -> TaskWatcher for the content browser. + property var contentInstaller: null + // (anchor, instanceId) -> PluginSurfaceModel. + property var pluginSurfacesFor: null + // The account, for the top-right ProfileButton -- this page has no + // TopBar of its own (see Main.qml), so it carries the same spot here. + property string accountName: "" + property string accountKind: "" + property string accountAvatarSource: "" + property var accountsController: null + signal openAccountsRequested() + readonly property string instanceId: details ? details.instanceId : "" + readonly property var pagePlugins: pluginSurfacesFor && instanceId.length > 0 ? pluginSurfacesFor(1, instanceId) : null + readonly property var settingsPlugins: pluginSurfacesFor && instanceId.length > 0 ? pluginSurfacesFor(2, instanceId) : null + + // Play/Stop no longer round-trip through this page: the persistent play + // bar plays and stops whatever instance is open (see Main.qml). + signal backRequested() + signal classicEditorRequested(string id) + signal openPathRequested(string path) + // Bubbles up from the Servers tab's "Join" button - see Main.qml. + signal joinServerRequested(string id, string address) + + readonly property var modsModel: details ? details.mods : null + readonly property var worldsModel: details ? details.worlds : null + readonly property var shotsModel: details ? details.screenshots : null + readonly property var managedPackModel: details ? details.managedPack : null + // Set from WorldsTab's per-row "Data packs" action, and read by + // DataPacksTab's own selectedWorldRow - see both. Reset whenever a + // different instance opens, so a row picked in a previous instance + // never carries over onto one that may not even have that many worlds. + property int pendingDataPackWorld: -1 + onDetailsChanged: root.pendingDataPackWorld = -1 + + ColumnLayout { + anchors.fill: parent + anchors.leftMargin: Theme.space.xl + Theme.space.xs + anchors.rightMargin: Theme.space.xl + Theme.space.xs + // Theme.space.sm, matching TopBar's own title-row top margin exactly + // (see TopBar.qml): this page has no TopBar of its own, but the + // account still has to land in the same pixel spot switching to and + // from a page that does. + anchors.topMargin: Theme.space.sm + anchors.bottomMargin: Theme.space.lg + spacing: Theme.space.md + + RowLayout { + Layout.fillWidth: true + // Theme.control.height, matching TopBar.titleRowHeight -- see + // the topMargin comment above. + Layout.preferredHeight: Theme.control.height + spacing: Theme.space.sm + + Button { + flat: true + text: qsTr("Library") + icon.source: Icons.url("chevron-left") + leftPadding: Theme.space.sm + onClicked: root.backRequested() + } + + Item { Layout.fillWidth: true } + + // No TopBar reaches this page (see Main.qml) -- the account + // still needs the same consistent top-right spot every other + // page gives it. + ProfileButton { + name: root.accountName + kind: root.accountKind + avatarSource: root.accountAvatarSource + controller: root.accountsController + onOpenAccountsRequested: root.openAccountsRequested() + } + } + + Repeater { + model: root.headerModel + delegate: ContinueCard { + required property string group + Layout.fillWidth: true + // The group this instance lives in is more useful here than + // a label that just repeats "you are looking at an + // instance" -- and an ungrouped instance shows nothing. + overline: group.length > 0 ? group : "" + compact: true + // Outside the Overview the tab's own content needs the room. + slim: root.tab !== "overview" + // The persistent play bar plays/stops the opened instance + // now; this header no longer needs its own Play/Stop too. + showPlay: false + // The "Classic editor" button that used to sit here opened + // a widget InstanceWindow, which the QML shell must never + // do -- this page's own tabs (Servers, Backups, Worlds, + // Managed pack, ...) now cover everything it did instead. + // classicEditorRequested is kept below only because Main.qml + // still has a handler for it; nothing emits it any more. + showEdit: false + // Nothing under the QML shell answers this yet either. + showMenu: false + onFolderRequested: root.openPathRequested(root.details ? root.details.instanceRoot : "") + + // The overview needs these too, and this delegate is the + // only place the header model's roles are at hand. + Component.onCompleted: overview.syncFrom(this) + onLastLaunchChanged: overview.syncFrom(this) + onTotalTimePlayedChanged: overview.syncFrom(this) + onGameVersionChanged: overview.syncFrom(this) + onLoaderChanged: overview.syncFrom(this) + onIsRunningChanged: overview.running = isRunning + } + } + + TabStrip { + Layout.fillWidth: true + current: root.tab + tabs: [ + { id: "overview", label: qsTr("Overview") } + ].concat(root.managedPackModel ? [{ id: "managedpack", label: qsTr("Modpack") }] : []) + .concat([ + { id: "content", label: qsTr("Content"), count: root.modsModel ? contentTab.count : -1 }, + { id: "version", label: qsTr("Version") }, + { id: "browse", label: qsTr("Add content") }, + { id: "worlds", label: qsTr("Worlds"), count: root.worldsModel ? worldsTab.count : -1 } + ]) + .concat(root.details && root.details.isMinecraft + ? [{ id: "datapacks", label: qsTr("Data packs") }, + { id: "servers", label: qsTr("Servers") }] : []) + .concat([ + { id: "backups", label: qsTr("Backups") }, + { id: "screenshots", label: qsTr("Screenshots"), count: root.shotsModel ? root.shotsModel.count : -1 }, + { id: "log", label: qsTr("Log") }, + { id: "settings", label: qsTr("Settings") } + ]) + .concat(root.details && root.details.isMinecraft + ? [{ id: "gameoptions", label: qsTr("Game options") }] : []) + .concat(pluginPages.count === 0 ? [] + : [{ id: "plugins", label: pluginPages.count === 1 && pluginPages.firstTitle.length > 0 + ? pluginPages.firstTitle : qsTr("Plugins") }]) + onActivated: (id) => root.tab = id + } + + StackLayout { + Layout.fillWidth: true + Layout.fillHeight: true + Layout.topMargin: Theme.space.sm + currentIndex: ["overview", "managedpack", "content", "version", "browse", "worlds", "datapacks", "servers", "backups", "screenshots", "log", "settings", "gameoptions", "plugins"].indexOf(root.tab) + + InstanceOverviewTab { + id: overview + property bool running: false + function syncFrom(card) { + loader = card.loader + gameVersion = card.gameVersion + lastLaunch = card.lastLaunch + totalTimePlayed = card.totalTimePlayed + } + details: root.details + notes: root.details ? root.details.notes : "" + onNotesEdited: (text) => { if (root.details) root.details.notes = text } + onScreenshotsRequested: root.tab = "screenshots" + onManageContentRequested: root.tab = "content" + } + + ManagedPackTab { + controller: root.managedPackModel + onUpdated: root.backRequested() + } + + ContentTab { + id: contentTab + details: root.details + onOpenFolderRequested: (path) => root.openPathRequested(path) + onBrowseRequested: root.tab = "browse" + } + + VersionTab { + details: root.details + } + + ContentBrowserView { + browser: root.details ? root.details.contentBrowser : null + installer: root.contentInstaller + } + + WorldsTab { + id: worldsTab + details: root.details + onOpenFolderRequested: (path) => root.openPathRequested(path) + onDataPacksRequested: (row) => { + root.pendingDataPackWorld = row + root.tab = "datapacks" + } + } + + DataPacksTab { + details: root.details + selectedWorldRow: root.pendingDataPackWorld + onOpenFolderRequested: (path) => root.openPathRequested(path) + } + + ServersTab { + model: root.details ? root.details.servers : null + serversDir: root.details ? root.details.serversDir : "" + onOpenFolderRequested: (path) => root.openPathRequested(path) + onJoinRequested: (address) => root.joinServerRequested(root.instanceId, address) + } + + BackupsTab { + controller: root.details ? root.details.backups : null + } + + ScreenshotsTab { + model: root.shotsModel + directory: root.details ? root.details.screenshotsDir : "" + onOpenFolderRequested: (path) => root.openPathRequested(path) + } + + LogTab { + log: root.details ? root.details.log : null + otherLogs: root.details ? root.details.otherLogs : null + onOpenFolderRequested: (path) => root.openPathRequested(path) + } + + InstanceSettingsTab { + pluginSurfaces: root.settingsPlugins + adapter: root.details ? root.details.settings : null + systemMemoryMiB: root.systemMemoryMiB + running: overview.running + } + + GameOptionsTab { + model: root.details ? root.details.gameOptions : null + } + + SettingsScroll { + title: qsTr("Plugins") + PluginSurfaces { + id: pluginPages + width: parent.width + model: root.pagePlugins + // A single plugin page is already named by its tab. + showTitles: count > 1 + } + } + } + } +} diff --git a/launcher/qml/Components/InstanceSection.qml b/launcher/qml/Components/InstanceSection.qml new file mode 100644 index 00000000..edd56048 --- /dev/null +++ b/launcher/qml/Components/InstanceSection.qml @@ -0,0 +1,178 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import MeshMC.Theme + +/* + * One group of the library: a collapsible header and a grid of cards. + * Column count and card width come from the page so every section lines + * up with every other one. + */ +Column { + id: root + + property string title + // The group these instances are in; "" for ungrouped. + property string group + property bool showHeader: true + property var model: null + property int columns: 4 + property real cardWidth: 208 + property int gutter: Theme.space.lg + property string selectedId + property bool collapsed: false + // "grid" (InstanceCard tiles) or "list" (dense InstanceListRow rows). + property string viewMode: "grid" + + signal selectRequested(string id) + signal launchRequested(string id) + signal stopRequested(string id) + signal menuRequested(string id, bool running, string name, string iconKey, string group) + signal toggleRequested() + + spacing: Theme.space.md + + Item { + visible: root.showHeader + width: parent.width + height: Theme.control.height + + AbstractButton { + id: header + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + height: parent.height + width: headerRow.implicitWidth + Theme.space.sm + hoverEnabled: true + Accessible.name: root.title + onClicked: root.toggleRequested() + + contentItem: Row { + id: headerRow + spacing: Theme.space.sm + + MeshIcon { + anchors.verticalCenter: parent.verticalCenter + iconName: "chevron-down" + size: Theme.icon.sm + rotation: root.collapsed ? -90 : 0 + color: header.hovered ? Theme.palette.textPrimary : Theme.palette.textTertiary + Behavior on rotation { NumberAnimation { duration: Theme.motion.normal; easing.type: Theme.motion.easing } } + } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: root.title + color: Theme.palette.textPrimary + font.family: Theme.font.family + font.pixelSize: Theme.type.title.pixelSize + 1 + font.weight: Font.Bold + font.letterSpacing: -0.1 + } + + Tag { + anchors.verticalCenter: parent.verticalCenter + visible: !!root.model + text: root.model ? String(root.model.count) : "" + } + } + background: Item {} + } + + Rectangle { + anchors.left: header.right + anchors.leftMargin: Theme.space.md + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + height: 1 + color: Theme.palette.divider + } + } + + // Clipped to an animated height rather than toggling `visible` outright, + // so a fold/unfold reflows the sections below it instead of jumping. + Item { + id: body + readonly property real fullHeight: contentLoader.item ? contentLoader.item.implicitHeight : 0 + width: parent.width + height: root.collapsed ? 0 : fullHeight + // Only while folded or folding: a permanent clip would also cut off + // a card's hover lift and focus ring along the top row. + clip: height < fullHeight - 0.5 + + // Off for the section's own initial layout -- height jumps straight + // from 0 to the grid/list's real height as soon as the Loader below + // first produces content, and animating that first jump (rather + // than a later, real fold/unfold click) is exactly the kind of + // startup work that made the app hang on Windows. Qt.callLater runs + // after that first layout pass has settled, so a genuine click a + // moment later still gets the fold animation. + property bool settled: false + Component.onCompleted: Qt.callLater(function () { body.settled = true }) + + Behavior on height { + enabled: body.settled + NumberAnimation { duration: Theme.motion.normal; easing.type: Theme.motion.easing } + } + + Loader { + id: contentLoader + // No explicit width: each Component below sizes its own root + // item (the grid packs to its own columns*cardWidth, the list + // binds to `body.width` itself), so the Loader is left free to + // just take whichever one it is showing rather than fight it. + sourceComponent: root.viewMode === "list" ? listComponent : gridComponent + } + } + + Component { + id: gridComponent + Grid { + columns: root.columns + columnSpacing: root.gutter + rowSpacing: root.gutter + + Repeater { + model: root.model + delegate: InstanceCard { + width: root.cardWidth + selected: root.selectedId === instanceId + onClicked: root.selectRequested(instanceId) + onPlayRequested: root.launchRequested(instanceId) + onStopRequested: root.stopRequested(instanceId) + onMenuRequested: { + root.selectRequested(instanceId) + root.menuRequested(instanceId, isRunning, name, iconKey, root.group) + } + } + } + } + } + + Component { + id: listComponent + Column { + width: body.width + spacing: Theme.space.xs + + Repeater { + model: root.model + delegate: InstanceListRow { + width: body.width + selected: root.selectedId === instanceId + onClicked: root.selectRequested(instanceId) + onPlayRequested: root.launchRequested(instanceId) + onStopRequested: root.stopRequested(instanceId) + onMenuRequested: { + root.selectRequested(instanceId) + root.menuRequested(instanceId, isRunning, name, iconKey, root.group) + } + } + } + } + } +} diff --git a/launcher/qml/Components/InstanceSettingsTab.qml b/launcher/qml/Components/InstanceSettingsTab.qml new file mode 100644 index 00000000..8acbb0c5 --- /dev/null +++ b/launcher/qml/Components/InstanceSettingsTab.qml @@ -0,0 +1,231 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import MeshMC.Theme + +/* + * One instance's own settings: each group follows the launcher-wide value + * until switched to "custom for this instance". Locked while the instance + * runs -- the game already read these, a change now would silently do + * nothing until the next launch. + */ +SettingsScroll { + id: root + + // SettingsAdapter over the instance's settings. + property var adapter: null + property int systemMemoryMiB: 8192 + property bool running: false + // PluginSurfaceModel for this instance's settings anchor, or null. + property var pluginSurfaces: null + + title: qsTr("Instance settings") + + SettingsSource { + id: store + adapter: root.adapter + } + + Rectangle { + visible: root.running + width: parent.width + height: lockedText.implicitHeight + Theme.space.md * 2 + radius: Theme.radius.lg + color: Theme.palette.warningSubtle + + Text { + id: lockedText + anchors.fill: parent + anchors.margins: Theme.space.md + text: qsTr("The game is running. Settings can be changed once it has closed.") + wrapMode: Text.Wrap + verticalAlignment: Text.AlignVCenter + color: Theme.palette.warning + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + font.weight: Font.Medium + } + } + + Column { + width: parent.width + spacing: Theme.space.xl + enabled: !root.running + + OverrideGroup { + id: memory + width: parent.width + title: qsTr("Memory") + source: store + gateKey: "OverrideMemory" + keys: ["MinMemAlloc", "MaxMemAlloc", "PermGen"] + MemorySetting { + source: store + enabled: memory.overriding + label: qsTr("Maximum memory") + systemMiB: root.systemMemoryMiB + } + SettingNumber { + source: store + key: "MinMemAlloc" + enabled: memory.overriding + label: qsTr("Starting memory") + from: 128 + to: store.number("MaxMemAlloc") + stepSize: 128 + suffix: qsTr("MiB") + } + } + + OverrideGroup { + id: javaPath + width: parent.width + title: qsTr("Java") + source: store + gateKey: "OverrideJavaLocation" + gateLabel: qsTr("Use a specific Java for this instance") + keys: ["JavaPath"] + SettingText { + source: store + key: "JavaPath" + enabled: javaPath.overriding + label: qsTr("Java executable") + monospace: true + } + } + + OverrideGroup { + id: javaArgs + width: parent.width + title: qsTr("JVM arguments") + source: store + gateKey: "OverrideJavaArgs" + gateLabel: qsTr("Use custom JVM arguments for this instance") + keys: ["JvmArgs"] + SettingText { + source: store + key: "JvmArgs" + enabled: javaArgs.overriding + label: qsTr("Arguments") + monospace: true + } + } + + OverrideGroup { + id: windowGroup + width: parent.width + title: qsTr("Game window") + source: store + gateKey: "OverrideWindow" + keys: ["LaunchMaximized", "MinecraftWinWidth", "MinecraftWinHeight"] + SettingSwitch { + id: maximized + source: store + key: "LaunchMaximized" + enabled: windowGroup.overriding + label: qsTr("Start maximized") + } + SettingNumber { + source: store + key: "MinecraftWinWidth" + enabled: windowGroup.overriding && !maximized.checked + label: qsTr("Window width") + from: 1 + to: 65536 + suffix: qsTr("px") + } + SettingNumber { + source: store + key: "MinecraftWinHeight" + enabled: windowGroup.overriding && !maximized.checked + label: qsTr("Window height") + from: 1 + to: 65536 + suffix: qsTr("px") + } + } + + OverrideGroup { + id: consoleGroup + width: parent.width + title: qsTr("Console") + source: store + gateKey: "OverrideConsole" + keys: ["ShowConsole", "AutoCloseConsole", "ShowConsoleOnError"] + SettingSwitch { + source: store + key: "ShowConsole" + enabled: consoleGroup.overriding + label: qsTr("Show the console while playing") + } + SettingSwitch { + source: store + key: "AutoCloseConsole" + enabled: consoleGroup.overriding + label: qsTr("Close the console when the game quits") + } + SettingSwitch { + source: store + key: "ShowConsoleOnError" + enabled: consoleGroup.overriding + label: qsTr("Show the console when the game crashes") + } + } + + OverrideGroup { + id: commands + width: parent.width + title: qsTr("Custom commands") + source: store + gateKey: "OverrideCommands" + keys: ["PreLaunchCommand", "WrapperCommand", "PostExitCommand"] + SettingText { + source: store + key: "PreLaunchCommand" + enabled: commands.overriding + label: qsTr("Before launch") + monospace: true + } + SettingText { + source: store + key: "WrapperCommand" + enabled: commands.overriding + label: qsTr("Wrapper") + monospace: true + } + SettingText { + source: store + key: "PostExitCommand" + enabled: commands.overriding + label: qsTr("After exit") + monospace: true + } + } + + PluginSurfaces { + width: parent.width + model: root.pluginSurfaces + } + + SettingsGroup { + width: parent.width + title: qsTr("On launch") + SettingSwitch { + id: joinServer + source: store + key: "JoinServerOnLaunch" + label: qsTr("Join a server when the game starts") + } + SettingText { + source: store + key: "JoinServerOnLaunchAddress" + enabled: joinServer.checked + label: qsTr("Server address") + placeholder: "play.example.net" + } + } + } +} diff --git a/launcher/qml/Components/LaunchProgressBar.qml b/launcher/qml/Components/LaunchProgressBar.qml new file mode 100644 index 00000000..36ea8654 --- /dev/null +++ b/launcher/qml/Components/LaunchProgressBar.qml @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import MeshMC.Theme + +/* + * A thin bar for a launch in progress. `progress` 0..1 fills it; a negative + * value means "busy, no idea how far" and runs a sliding segment instead. + */ +Rectangle { + id: root + + property real progress: -1 + property bool onMedia: false + // The play bar's XP-bar-like look: thin ticks over the whole track + // rather than one smooth fill, evoking a game's own experience bar. + property bool segmented: false + readonly property bool indeterminate: progress < 0 + + implicitHeight: 4 + radius: height / 2 + clip: true + color: onMedia ? Qt.rgba(1, 1, 1, 0.18) : Theme.palette.surfaceOverlay + + Rectangle { + id: fill + height: parent.height + radius: parent.radius + color: Theme.palette.accent + width: root.indeterminate ? parent.width * 0.3 : parent.width * Math.max(0, Math.min(1, root.progress)) + x: root.indeterminate ? slide.position : 0 + Behavior on width { + enabled: !root.indeterminate + NumberAnimation { duration: Theme.motion.normal; easing.type: Theme.motion.easing } + } + } + + Row { + visible: root.segmented + anchors.fill: parent + spacing: 0 + + Repeater { + model: root.segmented ? 16 : 0 + delegate: Item { + required property int index + width: root.width / 16 + height: parent.height + + Rectangle { + visible: index < 15 + anchors.right: parent.right + width: 1 + height: parent.height + color: Qt.rgba(0, 0, 0, 0.25) + } + } + } + } + + QtObject { + id: slide + property real position: -fill.width + } + + NumberAnimation { + target: slide + property: "position" + from: -fill.width + to: root.width + duration: 1100 + easing.type: Easing.InOutQuad + loops: Animation.Infinite + running: root.indeterminate && root.visible + } +} diff --git a/launcher/qml/Components/LibraryPage.qml b/launcher/qml/Components/LibraryPage.qml new file mode 100644 index 00000000..ce87a39b --- /dev/null +++ b/launcher/qml/Components/LibraryPage.qml @@ -0,0 +1,262 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import MeshMC.Theme + +/* + * The instance library: every group as its own section. The instance most + * likely to play next no longer gets its own hero card here -- the + * persistent play bar (PlayDock, in Main.qml) replaces it, showing on every + * page rather than just this one. + * + * The page never builds models itself. It is handed the search-filtered + * instance model and a function that returns the model for one group -- all + * C++ proxies, owned by the shell. + */ +Item { + id: root + + property var instanceModel: null + property var sectionModelFor: function (group) { return null } + property string searchText: "" + property string selectedId: "" + // "grid" (InstanceCard tiles) or "list" (dense InstanceListRow rows); + // session-only -- there is no persisted setting for it yet. + property string viewMode: "grid" + + signal selectRequested(string id) + signal launchRequested(string id) + signal stopRequested(string id) + signal editRequested(string id) + signal folderRequested(string id) + signal createRequested() + signal renameRequested(string id, string name) + signal iconRequested(string id, string iconKey) + signal groupRequested(string id, string group) + signal duplicateRequested(string id, string name, string group) + signal deleteRequested(string id, string name) + signal clearSearchRequested() + + readonly property int pagePadding: Theme.space.xl + Theme.space.xs + readonly property int gutter: Theme.space.lg + readonly property int minCardWidth: 184 + readonly property int maxCardWidth: 240 + readonly property int contentWidth: Math.max(0, flick.width - pagePadding * 2) + readonly property int columns: columnsFor(contentWidth) + readonly property real cardWidth: Math.floor((contentWidth - gutter * (columns - 1)) / columns) + + readonly property bool searching: searchText.length > 0 + readonly property var groups: instanceModel && instanceModel.groups ? instanceModel.groups : [] + readonly property int instanceCount: instanceModel && instanceModel.count !== undefined ? instanceModel.count : 0 + + // Session-only: which groups the user folded away. + property var collapsedGroups: ({}) + + function columnsFor(width) { + var columns = Math.max(1, Math.floor((width + gutter) / (minCardWidth + gutter))) + while ((width - gutter * (columns - 1)) / columns > maxCardWidth) + columns++ + return columns + } + + Keys.onEscapePressed: root.selectRequested("") + + // -- Backdrop: the most recently played instance's own cover art ------- + // "arka planlarda hiçbir şey yok" (nothing behind the content) -- the + // page was flat Theme.palette.canvas below the header. A full-bleed, + // heavily scrimmed crop of the same art the instance's own card already + // shows (CoverArt's fallback plate when it has no screenshot yet) reads + // as MeshMC's own content, never stock art (design-plan.md §5/§6). + // instanceModel has no "most recent" query of its own (that is + // recentModel's job, not given to this page) -- found here the same way + // AccountsPage finds its hero row: a zero-size probe per instance, + // reduced down to the newest lastLaunch. + property string backdropCover: "" + property color backdropTint: Theme.palette.textTertiary + + function _recomputeBackdrop() { + var bestMs = -1 + var cover = "" + var tint = Theme.palette.textTertiary + for (var i = 0; i < backdropProbe.count; ++i) { + var item = backdropProbe.itemAt(i) + if (!item) + continue + var ms = Number(item.lastLaunch) || 0 + if (ms > bestMs) { + bestMs = ms + cover = item.coverImage + tint = item.iconTint + } + } + root.backdropCover = cover + root.backdropTint = tint + } + + Repeater { + id: backdropProbe + model: root.instanceModel + // Cover art is scanned off the GUI thread and lastLaunch moves on + // every launch, so a row's roles change after it was counted -- + // recompute on those too, coalesced into one pass per frame. + delegate: Item { + required property string coverImage + required property color iconTint + required property var lastLaunch + visible: false + width: 0 + height: 0 + onCoverImageChanged: Qt.callLater(root._recomputeBackdrop) + onLastLaunchChanged: Qt.callLater(root._recomputeBackdrop) + } + onCountChanged: Qt.callLater(root._recomputeBackdrop) + } + + // CoverArt's generated fallback plate is worth showing even without a + // real screenshot yet, so this is gated on having any instance at all. + PageBackdrop { + anchors.fill: parent + visible: root.instanceCount > 0 + cover: root.backdropCover + tint: root.backdropTint + } + + Flickable { + id: flick + anchors.fill: parent + contentWidth: width + contentHeight: content.y + content.height + root.pagePadding + boundsBehavior: Flickable.StopAtBounds + clip: true + + ScrollBar.vertical: ScrollBar {} + + // Clicking the empty page clears the selection. + MouseArea { + width: flick.width + height: Math.max(flick.height, flick.contentHeight) + onClicked: root.selectRequested("") + } + + Column { + id: content + x: root.pagePadding + y: Theme.space.xs + width: root.contentWidth + spacing: Theme.space.xl + Theme.space.sm + + Repeater { + model: root.groups + delegate: InstanceSection { + required property string modelData + width: content.width + title: modelData.length > 0 ? modelData : qsTr("Ungrouped") + // A library that never used groups gets no header at all. + showHeader: root.groups.length > 1 || modelData.length > 0 + group: modelData + model: root.sectionModelFor(modelData) + columns: root.columns + cardWidth: root.cardWidth + gutter: root.gutter + selectedId: root.selectedId + viewMode: root.viewMode + collapsed: !root.searching && root.collapsedGroups[modelData] === true + onToggleRequested: { + var next = Object.assign({}, root.collapsedGroups) + next[modelData] = !(next[modelData] === true) + root.collapsedGroups = next + } + onSelectRequested: (id) => root.selectRequested(id) + onLaunchRequested: (id) => root.launchRequested(id) + onStopRequested: (id) => root.stopRequested(id) + onMenuRequested: (id, running, name, iconKey, group) => instanceMenu.openFor(id, running, name, iconKey, group) + } + } + } + } + + EmptyState { + anchors.centerIn: parent + visible: root.instanceCount === 0 + title: root.searching ? qsTr("No instances match “%1”").arg(root.searchText) + : qsTr("No instances yet") + body: root.searching ? qsTr("Check the spelling, or search for part of the name.") + : qsTr("An instance is one Minecraft setup: a version, a mod loader and its mods. Create one to start playing.") + actionText: root.searching ? qsTr("Clear search") : qsTr("Create instance") + actionIcon: root.searching ? "x" : "plus" + onActionTriggered: root.searching ? root.clearSearchRequested() : root.createRequested() + + MeshIcon { + iconName: root.searching ? "search" : "cube" + size: 40 + color: Theme.palette.textTertiary + } + } + + Menu { + id: instanceMenu + + property string targetId + property bool targetRunning: false + property string targetName + property string targetIcon + property string targetGroup + + function openFor(id, running, name, iconKey, group) { + targetId = id + targetRunning = running + targetName = name || "" + targetIcon = iconKey || "" + targetGroup = group || "" + popup() + } + + MenuItem { + text: instanceMenu.targetRunning ? qsTr("Stop") : qsTr("Play") + icon.source: Icons.url(instanceMenu.targetRunning ? "stop" : "play") + onTriggered: instanceMenu.targetRunning ? root.stopRequested(instanceMenu.targetId) + : root.launchRequested(instanceMenu.targetId) + } + MenuItem { + text: qsTr("Open") + icon.source: Icons.url("settings") + onTriggered: root.editRequested(instanceMenu.targetId) + } + MenuItem { + text: qsTr("Open folder") + icon.source: Icons.url("folder") + onTriggered: root.folderRequested(instanceMenu.targetId) + } + MenuSeparator {} + MenuItem { + text: qsTr("Rename\u2026") + icon.source: Icons.url("edit") + onTriggered: root.renameRequested(instanceMenu.targetId, instanceMenu.targetName) + } + MenuItem { + text: qsTr("Change icon\u2026") + icon.source: Icons.url("image") + onTriggered: root.iconRequested(instanceMenu.targetId, instanceMenu.targetIcon) + } + MenuItem { + text: qsTr("Move to group\u2026") + icon.source: Icons.url("layers") + onTriggered: root.groupRequested(instanceMenu.targetId, instanceMenu.targetGroup) + } + MenuItem { + text: qsTr("Duplicate\u2026") + icon.source: Icons.url("copy") + onTriggered: root.duplicateRequested(instanceMenu.targetId, instanceMenu.targetName, instanceMenu.targetGroup) + } + MenuSeparator {} + MenuItem { + text: qsTr("Delete\u2026") + icon.source: Icons.url("trash") + enabled: !instanceMenu.targetRunning + onTriggered: root.deleteRequested(instanceMenu.targetId, instanceMenu.targetName) + } + } +} diff --git a/launcher/qml/Components/LoaderInstallDialog.qml b/launcher/qml/Components/LoaderInstallDialog.qml new file mode 100644 index 00000000..a21b7d06 --- /dev/null +++ b/launcher/qml/Components/LoaderInstallDialog.qml @@ -0,0 +1,205 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import MeshMC.Theme + +/* + * Pick a mod loader and a version of it, and install it - the QML + * replacement for InstallLoaderDialog. Turning off a conflicting loader + * (LoaderInstaller.conflictName) is confirmed here rather than asked about + * silently: the picker itself has no other destructive step. + */ +Dialog { + id: root + + // LoaderInstaller (InstanceDetails.loaderInstaller). + property var installer: null + // Loader uid to open on; empty opens the first one in the list. + property string preselectUid: "" + + parent: Overlay.overlay + anchors.centerIn: parent + width: Math.min(680, parent ? parent.width - Theme.space.xxl * 2 : 680) + height: Math.min(600, parent ? parent.height - Theme.space.xxl * 2 : 600) + modal: true + title: qsTr("Install a mod loader") + + header: DialogHeader { + title: root.title + icon: "download" + } + + onOpened: { + list.currentIndex = -1 + if (root.installer) { + var uid = root.preselectUid.length > 0 ? root.preselectUid + : (root.installer.loaders.length > 0 ? root.installer.loaders[0].uid : "") + if (uid.length > 0) { + root.installer.selectLoader(uid) + } + } + } + + Connections { + target: root.installer + function onSelectedUidChanged() { list.currentIndex = -1 } + } + Connections { + target: root.installer ? root.installer.versions : null + function onCountChanged() { + if (list.currentIndex < 0 && root.installer.versions.count > 0) { + list.currentIndex = 0 + } + } + } + + contentItem: ColumnLayout { + spacing: Theme.space.md + + SegmentedControl { + Layout.fillWidth: true + options: root.installer + ? root.installer.loaders.map(function (l) { return { value: l.uid, label: l.brandName } }) + : [] + current: root.installer ? root.installer.selectedUid : "" + onActivated: (value) => root.installer.selectLoader(value) + } + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + radius: Theme.radius.lg + color: Theme.palette.surfaceSunken + border.width: 1 + border.color: Theme.palette.border + + ListView { + id: list + anchors.fill: parent + anchors.margins: Theme.space.sm + clip: true + boundsBehavior: Flickable.StopAtBounds + visible: !!root.installer && root.installer.supported + model: root.installer ? root.installer.versions : null + ScrollBar.vertical: ScrollBar {} + + delegate: ItemDelegate { + id: cell + required property int index + required property string versionId + required property string version + required property bool recommended + + width: list.width + height: Theme.control.heightLg + highlighted: ListView.isCurrentItem + + onClicked: list.currentIndex = index + onDoubleClicked: root.doInstall(cell.versionId) + + contentItem: RowLayout { + spacing: Theme.space.sm + Text { + Layout.fillWidth: true + text: cell.version + color: Theme.palette.textPrimary + font.family: Theme.font.family + font.pixelSize: Theme.type.body.pixelSize + } + StatusBadge { + visible: cell.recommended + text: qsTr("Recommended") + tone: "success" + } + } + } + } + + BusyIndicator { + anchors.centerIn: parent + visible: running + running: !!root.installer && !!root.installer.versions && root.installer.versions.loading + } + + EmptyState { + anchors.centerIn: parent + visible: !!root.installer && root.installer.supported && !!root.installer.versions + && !root.installer.versions.loading && root.installer.versions.count === 0 + title: qsTr("No versions found") + body: root.installer && root.installer.versions.error.length > 0 + ? root.installer.versions.error + : qsTr("Nothing is published for this Minecraft version yet.") + MeshIcon { iconName: "download"; size: 40; color: Theme.palette.textTertiary } + } + + EmptyState { + anchors.centerIn: parent + visible: !!root.installer && !root.installer.supported + title: qsTr("Not compatible") + body: root.installer ? root.installer.unsupportedReason : "" + MeshIcon { iconName: "alert-triangle"; size: 40; color: Theme.palette.textTertiary } + } + } + + Text { + Layout.fillWidth: true + visible: !!root.installer && root.installer.installedVersion.length > 0 + text: root.installer ? qsTr("Already installed: %1").arg(root.installer.installedVersion) : "" + color: Theme.palette.textTertiary + font.family: Theme.font.family + font.pixelSize: Theme.type.caption.pixelSize + } + } + + footer: Row { + layoutDirection: Qt.RightToLeft + spacing: Theme.space.sm + padding: Theme.space.lg + topPadding: 0 + + Button { + text: qsTr("Install") + enabled: list.currentIndex >= 0 + onClicked: root.doInstall(list.currentItem ? list.currentItem.versionId : "") + } + Button { + text: qsTr("Cancel") + flat: true + onClicked: root.close() + } + } + + function doInstall(versionId) { + if (!root.installer || versionId.length === 0) { + return + } + if (root.installer.conflictName.length > 0) { + var picked = root.installer.loaders.find(function (l) { return l.uid === root.installer.selectedUid }) + var brand = picked ? picked.brandName : qsTr("This loader") + conflictConfirm.versionId = versionId + conflictConfirm.text = qsTr("%1 and %2 hook into the same parts of the game and cannot run together. Installing this turns %1 off.") + .arg(root.installer.conflictName).arg(brand) + conflictConfirm.open() + return + } + if (root.installer.install(versionId)) { + root.close() + } + } + + ConfirmDialog { + id: conflictConfirm + property string versionId: "" + title: qsTr("Turn off the other loader?") + confirmText: qsTr("Install anyway") + onConfirmed: { + if (root.installer.install(versionId)) { + root.close() + } + } + } +} diff --git a/launcher/qml/Components/LogTab.qml b/launcher/qml/Components/LogTab.qml new file mode 100644 index 00000000..56b7d238 --- /dev/null +++ b/launcher/qml/Components/LogTab.qml @@ -0,0 +1,232 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import MeshMC.Theme + +/* + * The game's output, live, plus (when the instance has any) its other log + * files -- logs/*.log*, crash-reports/*.txt -- the widget-free replacement + * for OtherLogsPage, folded into this tab rather than given its own: both + * are "read a log", just live vs. on disk. + * + * "Live" follows the newest line only while "Follow" is on -- scrolling up + * to read something turns it off, instead of the view yanking the reader + * back down on every new line. + */ +Item { + id: root + + // InstanceLogBridge: model (LogModel: line, level), hasLog, clear(), + // text(). + property var log: null + // OtherLogsModel (roles: name), or null - see InstanceDetails.otherLogs. + property var otherLogs: null + readonly property var model: log ? log.model : null + + signal openFolderRequested(string path) + + // "live" or "other". + property string view: "live" + + // MessageLevel::Enum + function levelColor(level) { + switch (level) { + case 2: case 8: case 9: return Theme.palette.danger // StdErr, Error, Fatal + case 7: return Theme.palette.warning // Warning + case 3: return Theme.palette.accent // MeshMC + case 4: return Theme.palette.textTertiary // Debug + default: return Theme.palette.textSecondary + } + } + + ColumnLayout { + anchors.fill: parent + spacing: Theme.space.md + + RowLayout { + Layout.fillWidth: true + spacing: Theme.space.sm + + SegmentedControl { + visible: !!root.otherLogs + options: [ + { value: "live", label: qsTr("Live") }, + { value: "other", label: qsTr("Other logs") } + ] + current: root.view + onActivated: (value) => root.view = value + } + + Item { Layout.fillWidth: true } + + RowLayout { + visible: root.view === "live" + spacing: Theme.space.sm + + Switch { + id: follow + checked: true + text: qsTr("Follow") + } + Button { + enabled: !!root.model + text: qsTr("Copy all") + icon.source: Icons.url("copy") + onClicked: { + clipboardHelper.text = root.log.text() + clipboardHelper.selectAll() + clipboardHelper.copy() + } + } + Button { + enabled: !!root.model + flat: true + text: qsTr("Clear") + icon.source: Icons.url("x") + onClicked: root.log.clear() + } + } + + RowLayout { + visible: root.view === "other" + spacing: Theme.space.sm + + ComboBox { + id: fileCombo + Layout.preferredWidth: 260 + model: root.otherLogs + textRole: "name" + // Keeps its selection in sync with otherLogs.currentFile + // rather than owning the selection itself - a deleted + // file, or the model refreshing after one appears, + // should not silently pick something else. + currentIndex: { + if (!root.otherLogs) return -1 + for (var i = 0; i < count; i++) { + if (textAt(i) === root.otherLogs.currentFile) return i + } + return -1 + } + onActivated: (index) => root.otherLogs.selectFile(textAt(index)) + } + Button { + enabled: !!root.otherLogs && root.otherLogs.currentFile.length > 0 + text: qsTr("Copy") + icon.source: Icons.url("copy") + onClicked: { + clipboardHelper.text = root.otherLogs.content + clipboardHelper.selectAll() + clipboardHelper.copy() + } + } + Button { + enabled: !!root.otherLogs + flat: true + text: qsTr("Open folder") + icon.source: Icons.url("folder") + onClicked: root.openFolderRequested(root.otherLogs.path) + } + IconButton { + enabled: !!root.otherLogs && root.otherLogs.currentFile.length > 0 + iconName: "trash" + tip: qsTr("Delete this file") + onClicked: { + deleteFileConfirm.text = qsTr("Delete “%1”? It cannot be recovered from the launcher.").arg(root.otherLogs.currentFile) + deleteFileConfirm.open() + } + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + radius: Theme.radius.lg + color: Theme.palette.surfaceSunken + border.width: 1 + border.color: Theme.palette.border + + ListView { + id: lines + anchors.fill: parent + anchors.margins: Theme.space.sm + visible: root.view === "live" + clip: true + model: root.model + boundsBehavior: Flickable.StopAtBounds + reuseItems: true + ScrollBar.vertical: ScrollBar {} + + onCountChanged: if (follow.checked) positionViewAtEnd() + onMovementStarted: follow.checked = false + onAtYEndChanged: if (atYEnd && moving) follow.checked = true + + delegate: Text { + required property string line + required property int level + width: lines.width - Theme.space.md + text: line + wrapMode: Text.WrapAnywhere + color: root.levelColor(level) + font.family: Theme.font.mono + font.pixelSize: Theme.type.label.pixelSize - 1 + textFormat: Text.PlainText + } + } + + EmptyState { + anchors.centerIn: parent + upperThird: true + visible: root.view === "live" && (!root.model || lines.count === 0) + title: qsTr("No log yet") + body: qsTr("Start the instance and its output appears here, live.") + MeshIcon { iconName: "terminal"; size: 40; color: Theme.palette.textTertiary } + } + + ScrollView { + anchors.fill: parent + anchors.margins: Theme.space.sm + visible: root.view === "other" && !!root.otherLogs && root.otherLogs.currentFile.length > 0 + clip: true + + TextArea { + readOnly: true + text: root.otherLogs ? root.otherLogs.content : "" + wrapMode: TextArea.WrapAnywhere + selectByMouse: true + font.family: Theme.font.mono + font.pixelSize: Theme.type.label.pixelSize - 1 + background: Item {} + } + } + + EmptyState { + anchors.centerIn: parent + upperThird: true + visible: root.view === "other" && (!root.otherLogs || root.otherLogs.currentFile.length === 0) + title: fileCombo.count > 0 ? qsTr("No file selected") : qsTr("No other logs") + body: fileCombo.count > 0 + ? qsTr("Pick a file above to view it.") + : qsTr("Log files this instance writes outside the live console, such as rotated logs and crash reports, show up here.") + MeshIcon { iconName: "terminal"; size: 40; color: Theme.palette.textTertiary } + } + } + } + + // QML has no clipboard API of its own; an invisible text edit does. + TextEdit { + id: clipboardHelper + visible: false + } + + ConfirmDialog { + id: deleteFileConfirm + title: qsTr("Delete log file") + confirmText: qsTr("Delete") + onConfirmed: if (root.otherLogs) root.otherLogs.deleteCurrent() + } +} diff --git a/launcher/qml/Components/ManagedPackTab.qml b/launcher/qml/Components/ManagedPackTab.qml new file mode 100644 index 00000000..1702e0bd --- /dev/null +++ b/launcher/qml/Components/ManagedPackTab.qml @@ -0,0 +1,352 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import MeshMC.Theme + +/* + * Where this instance's modpack came from, and what other versions of it + * exist -- the widget-free replacement for ManagedPackPage, scoped to + * instances with a catalogue id (see ManagedPackController's class + * comment for what is deliberately not reproduced here). + */ +Item { + id: root + + // InstanceDetails.managedPack (ManagedPackController), or null. + property var controller: null + // The TaskWatcher of an update in progress, if any. + property var watcher: null + readonly property bool updating: !!root.watcher && root.watcher.running + + // Emitted once an update has landed on disk: this instance has just + // been replaced, so the page showing it -- including this tab's own + // controller -- is no longer trustworthy to keep looking at. See + // ManagedPackController::updateToVersion()'s own comment. + signal updated() + + // Fires once at creation (the property's initial binding still counts + // as a change from its declared `null` default) and again every time + // this tab's instance page opens a different instance - see + // VersionTab.qml's minecraftVersions binding for the same "read/bind + // triggers the fetch" idiom, here made explicit since fetchVersions() + // is a method, not a property getter. + onControllerChanged: { + root.watcher = null + if (root.controller) + root.controller.fetchVersions() + } + + // The lowercase URI scheme of "s" (e.g. "https" for "https://x"), or + // null if it has none. + function schemeOf(s) { + var m = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(String(s)) + return m ? m[1].toLowerCase() : null + } + + /* + * Mirrors ManagedPackPage.cpp's anchorClicked guard exactly: a + * changelog is remote, untrusted text, and handing an arbitrary + * scheme to the desktop is how a "file:" or worse ends up being + * opened, so only http(s) links are ever passed on. A schemeless + * link is CurseForge's outbound redirect, which in changelog HTML + * arrives as the relative "linkout?remoteUrl=" -- + * the real destination is in the query, decoded and scheme-checked + * the same way before it is allowed through. + */ + function openChangelogLink(link) { + var url = String(link) + var scheme = root.schemeOf(url) + if (scheme !== null) { + if (scheme === "http" || scheme === "https") + Qt.openUrlExternally(url) + else + console.warn("ManagedPackTab: refusing to open changelog link with scheme:", scheme) + return + } + + var queryIndex = url.indexOf("?") + var remote = "" + if (queryIndex >= 0) { + var parts = url.substring(queryIndex + 1).split("&") + for (var i = 0; i < parts.length; ++i) { + var eq = parts[i].indexOf("=") + var key = eq >= 0 ? parts[i].substring(0, eq) : parts[i] + if (key === "remoteUrl") { + var value = eq >= 0 ? parts[i].substring(eq + 1) : "" + try { + remote = decodeURIComponent(decodeURIComponent(value)) + } catch (e) { + remote = "" + } + break + } + } + } + + var remoteScheme = root.schemeOf(remote) + if (remoteScheme === "http" || remoteScheme === "https") + Qt.openUrlExternally(remote) + else if (remote.length > 0) + console.warn("ManagedPackTab: refusing to open changelog redirect with scheme:", remoteScheme) + } + + Connections { + target: root.watcher + ignoreUnknownSignals: true + function onFinished(ok) { + if (ok) + root.updated() + } + } + + ColumnLayout { + anchors.fill: parent + spacing: Theme.space.md + visible: !!root.controller + + RowLayout { + Layout.fillWidth: true + spacing: Theme.space.md + + Rectangle { + Layout.preferredWidth: 40 + Layout.preferredHeight: 40 + radius: Theme.radius.md + color: Theme.palette.surfaceSunken + MeshIcon { anchors.centerIn: parent; iconName: "package"; size: Theme.icon.sm; color: Theme.palette.textTertiary } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + Text { + text: root.controller ? root.controller.packName : "" + color: Theme.palette.textPrimary + font.family: Theme.font.family + font.pixelSize: Theme.type.title.pixelSize + font.weight: Theme.type.title.weight + } + RowLayout { + spacing: Theme.space.sm + Tag { text: root.controller ? root.controller.providerLabel : "" } + Text { + text: root.controller && root.controller.installedVersionName.length > 0 + ? qsTr("Installed: %1").arg(root.controller.installedVersionName) + : qsTr("Installed version unknown") + color: Theme.palette.textSecondary + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + } + } + } + + Button { + text: qsTr("Website") + icon.source: Icons.url("external-link") + visible: root.controller && root.controller.packUrl.length > 0 + onClicked: Qt.openUrlExternally(root.controller.packUrl) + } + } + + RowLayout { + Layout.fillWidth: true + visible: root.updating || (!!root.watcher && root.watcher.failed) + spacing: Theme.space.md + + Text { + Layout.fillWidth: true + text: root.watcher + ? (root.watcher.failed ? (root.watcher.error || qsTr("Update failed.")) + : (root.watcher.status || qsTr("Updating…"))) + : "" + color: root.watcher && root.watcher.failed ? Theme.palette.danger : Theme.palette.textSecondary + elide: Text.ElideRight + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + } + LaunchProgressBar { + Layout.preferredWidth: 160 + visible: root.updating + progress: root.watcher ? root.watcher.progress : -1 + } + } + + RowLayout { + Layout.fillWidth: true + visible: root.controller && !root.controller.hasPackId + Text { + Layout.fillWidth: true + wrapMode: Text.Wrap + text: qsTr("MeshMC does not have a catalogue id on record for this pack, so it cannot list other versions here. Install the pack again from Discover if you need a specific version.") + color: Theme.palette.textSecondary + font.family: Theme.font.family + font.pixelSize: Theme.type.body.pixelSize + } + } + + RowLayout { + Layout.fillWidth: true + visible: root.controller && root.controller.hasPackId && root.controller.error.length > 0 + spacing: Theme.space.sm + // What went wrong in plain words first, then the catalogue's own + // message (a raw job string, useful for a bug report) underneath. + ColumnLayout { + Layout.fillWidth: true + Layout.minimumWidth: 0 + spacing: 2 + Text { + Layout.fillWidth: true + text: qsTr("Could not load the versions of this pack. Check your connection, then reload.") + color: Theme.palette.danger + elide: Text.ElideRight + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + } + Text { + Layout.fillWidth: true + text: root.controller ? root.controller.error : "" + color: Theme.palette.textTertiary + elide: Text.ElideMiddle + font.family: Theme.font.family + font.pixelSize: Theme.type.caption.pixelSize + } + } + Button { + text: qsTr("Reload") + icon.source: Icons.url("refresh") + onClicked: root.controller.reload() + } + } + + RowLayout { + Layout.fillWidth: true + Layout.fillHeight: true + visible: root.controller && root.controller.hasPackId + spacing: Theme.space.md + + ListView { + id: list + Layout.preferredWidth: 280 + Layout.fillHeight: true + clip: true + spacing: Theme.space.xs + boundsBehavior: Flickable.StopAtBounds + model: root.controller ? root.controller.versions : [] + // A plain initial value, not a lasting binding: tapping a + // row below assigns this directly, which is what a plain + // QML property assignment does to whatever binding came + // before it - exactly what is wanted here, since the + // newest version (index 0) is the right thing to default + // to only until the user actually picks one themselves. + currentIndex: (root.controller && root.controller.versions.length > 0) ? 0 : -1 + ScrollBar.vertical: ScrollBar {} + + delegate: Rectangle { + id: verRow + required property int index + required property var modelData + + width: list.width - Theme.space.sm + height: Theme.control.heightLg + radius: Theme.radius.md + color: list.currentIndex === index ? Theme.palette.surfaceRaised : "transparent" + border.width: list.currentIndex === index ? 1 : 0 + border.color: Theme.palette.border + + TapHandler { onTapped: list.currentIndex = verRow.index } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: Theme.space.sm + anchors.rightMargin: Theme.space.sm + Text { + Layout.fillWidth: true + text: verRow.modelData.label + elide: Text.ElideRight + color: verRow.modelData.installable ? Theme.palette.textPrimary : Theme.palette.textTertiary + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + } + Tag { visible: verRow.modelData.current; text: qsTr("Current") } + } + } + } + + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: Theme.space.sm + + Rectangle { + id: changelogHost + Layout.fillWidth: true + Layout.fillHeight: true + radius: Theme.radius.lg + color: Theme.palette.surfaceSunken + border.width: 1 + border.color: Theme.palette.border + + readonly property var selected: { + const rows = root.controller ? root.controller.versions : [] + return list.currentIndex >= 0 && list.currentIndex < rows.length ? rows[list.currentIndex] : null + } + + Flickable { + anchors.fill: parent + anchors.margins: Theme.space.md + contentWidth: width + contentHeight: changelog.implicitHeight + clip: true + ScrollBar.vertical: ScrollBar {} + + Text { + id: changelog + width: parent.width + text: changelogHost.selected && changelogHost.selected.changelog.length > 0 + ? changelogHost.selected.changelog + : qsTr("No changelog available for this version.") + textFormat: Text.MarkdownText + wrapMode: Text.Wrap + color: Theme.palette.textSecondary + linkColor: Theme.palette.accent + font.family: Theme.font.family + font.pixelSize: Theme.type.body.pixelSize + onLinkActivated: (link) => root.openChangelogLink(link) + } + } + } + + Button { + Layout.alignment: Qt.AlignRight + enabled: !root.updating && list.currentIndex >= 0 && + root.controller && root.controller.versions.length > 0 && + root.controller.versions[list.currentIndex] && + root.controller.versions[list.currentIndex].installable && + !root.controller.versions[list.currentIndex].current + text: qsTr("Update to this version") + icon.source: Icons.url("download") + onClicked: root.watcher = root.controller.updateToVersion(list.currentIndex) + } + } + } + } + + BusyIndicator { + anchors.centerIn: parent + visible: root.controller && root.controller.loading + running: visible + } + + EmptyState { + anchors.centerIn: parent + upperThird: true + visible: !root.controller + title: qsTr("Not a managed pack") + body: qsTr("This instance was not installed from a catalogue MeshMC recognises.") + MeshIcon { iconName: "package"; size: 40; color: Theme.palette.textTertiary } + } +} diff --git a/launcher/qml/Components/MemorySetting.qml b/launcher/qml/Components/MemorySetting.qml new file mode 100644 index 00000000..1b270a3b --- /dev/null +++ b/launcher/qml/Components/MemorySetting.qml @@ -0,0 +1,125 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import MeshMC.Theme + +/* + * Maximum memory as a slider over the RAM this machine actually has, with + * the amount spelled out and a warning once it eats into what the system + * needs itself. Saved when the slider is let go. + * + * The minimum can never end up above the maximum: lowering the maximum + * below it pulls the minimum down too -- the widget page swapped the two + * instead, which silently turned the user's "max" into the min. + */ +SettingRow { + id: root + + property string key: "MaxMemAlloc" + // Where the value lives; the launcher-wide settings unless told otherwise. + property var source: SettingsStore + property string minKey: "MinMemAlloc" + property int systemMiB: 8192 + readonly property int step: 256 + readonly property int floor: 512 + readonly property int ceiling: Math.max(floor + step, Math.floor(systemMiB / step) * step) + readonly property int stored: root.source.number(root.key) + // Past three quarters of the machine's RAM the game starts competing + // with the OS and everything else that's open. + readonly property bool tooMuch: slider.value > systemMiB * 0.75 + + property string hint + description: tooMuch ? qsTr("That is most of this computer's %1 of memory; the system and other programs may slow down.").arg(describe(systemMiB)) + : hint + + function describe(mib) { + return mib >= 1024 ? qsTr("%1 GiB").arg(Number(mib / 1024).toLocaleString(Qt.locale(), "f", mib % 1024 === 0 ? 0 : 1)) + : qsTr("%1 MiB").arg(mib) + } + + Column { + width: sliderRow.width + spacing: Theme.space.sm + + Row { + id: sliderRow + spacing: Theme.space.md + + Slider { + id: slider + anchors.verticalCenter: parent.verticalCenter + width: 240 + from: root.floor + to: root.ceiling + stepSize: root.step + snapMode: Slider.SnapAlways + value: root.stored + Accessible.name: root.label + onPressedChanged: { + if (pressed) + return + var max = Math.round(value) + root.source.setValue(root.key, max) + if (root.source.number(root.minKey) > max) + root.source.setValue(root.minKey, max) + value = Qt.binding(() => root.stored) + } + } + + Text { + anchors.verticalCenter: parent.verticalCenter + width: 72 + horizontalAlignment: Text.AlignRight + text: root.describe(Math.round(slider.value)) + color: root.tooMuch ? Theme.palette.warning : Theme.palette.textPrimary + font.family: Theme.font.family + font.pixelSize: Theme.type.body.pixelSize + font.weight: Font.DemiBold + } + } + + // How much of the machine's own RAM that allocation is, at a + // glance -- the slider alone says "2.5 GiB" but not "of how much". + Column { + width: parent.width + spacing: Theme.space.xxs + + Rectangle { + width: parent.width + height: Theme.space.xs + radius: Theme.radius.pill + color: Theme.palette.surfaceSunken + + Rectangle { + width: parent.width * Math.max(0, Math.min(1, slider.value / root.systemMiB)) + height: parent.height + radius: parent.radius + color: root.tooMuch ? Theme.palette.warning : Theme.palette.accent + Behavior on width { + NumberAnimation { duration: Theme.motion.fast; easing.type: Theme.motion.easing } + } + } + + // Past this line the game starts competing with the OS for + // memory -- the same threshold `tooMuch` itself uses. + Rectangle { + x: parent.width * 0.75 - width / 2 + width: 2 + height: parent.height + color: Theme.palette.surface + opacity: 0.7 + } + } + + Text { + text: qsTr("%1 of %2 system memory").arg(root.describe(Math.round(slider.value))).arg(root.describe(root.systemMiB)) + color: Theme.palette.textTertiary + font.family: Theme.font.family + font.pixelSize: Theme.type.caption.pixelSize + } + } + } +} diff --git a/launcher/qml/Components/MeshIcon.qml b/launcher/qml/Components/MeshIcon.qml new file mode 100644 index 00000000..9c36edd4 --- /dev/null +++ b/launcher/qml/Components/MeshIcon.qml @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls.impl +import MeshMC.Theme + +/* + * A themed line icon: wraps IconImage so callers pick an icon by name from + * icons/ instead of building a source url and repeating the recolour + * boilerplate. The SVGs are pure #000000 on transparent so IconImage's + * alpha-channel recolouring (the same mechanism QtQuick.Controls.impl gives + * every built-in style icon) can tint them to any palette colour. + */ +IconImage { + id: root + + // Not `name`: IconImage already has a FINAL `name` (a theme icon name). + property string iconName + property int size: Theme.icon.md + + width: size + height: size + sourceSize: Qt.size(size, size) + source: iconName.length > 0 ? Qt.resolvedUrl("icons/" + iconName + ".svg") : "" + color: Theme.palette.textSecondary +} diff --git a/launcher/qml/Components/MinecraftVersionDialog.qml b/launcher/qml/Components/MinecraftVersionDialog.qml new file mode 100644 index 00000000..2fce4a9d --- /dev/null +++ b/launcher/qml/Components/MinecraftVersionDialog.qml @@ -0,0 +1,159 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import MeshMC.Theme + +/* + * Pick a Minecraft version for this instance - the QML replacement for the + * plain VersionSelectDialog VersionPage's "Change version" opens for the + * net.minecraft component. Snapshots and old versions are hidden by + * default, the same starting point VanillaPage gives a new instance. + */ +Dialog { + id: root + + // InstanceDetails. + property var details: null + // Not a binding to details.minecraftVersions: that getter builds the + // proxy and starts its download the first time anything reads it (see + // InstanceDetails::minecraftVersions()), and this dialog is + // instantiated eagerly with the rest of VersionTab.qml's children - + // reading it here would fetch the version list every time the + // instance page opens, whether or not this dialog ever does. Set from + // onOpened below instead, so opening the tab stays network-free - + // mirrors ContentBrowserView.qml's own visible-gated first search. + property var versions: null + + parent: Overlay.overlay + anchors.centerIn: parent + width: Math.min(560, parent ? parent.width - Theme.space.xxl * 2 : 560) + height: Math.min(600, parent ? parent.height - Theme.space.xxl * 2 : 600) + modal: true + title: qsTr("Change Minecraft version") + + header: DialogHeader { + title: root.title + icon: "cube" + } + + onOpened: { + list.currentIndex = -1 + if (root.details) { + root.versions = root.details.minecraftVersions + } + } + + contentItem: ColumnLayout { + spacing: Theme.space.md + + RowLayout { + Layout.fillWidth: true + spacing: Theme.space.md + + CheckBox { + text: qsTr("Snapshots") + checked: !!root.versions && root.versions.showSnapshots + onToggled: if (root.versions) root.versions.showSnapshots = checked + } + CheckBox { + text: qsTr("Old versions") + checked: !!root.versions && root.versions.showOldVersions + onToggled: if (root.versions) root.versions.showOldVersions = checked + } + Item { Layout.fillWidth: true } + } + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + radius: Theme.radius.lg + color: Theme.palette.surfaceSunken + border.width: 1 + border.color: Theme.palette.border + + ListView { + id: list + anchors.fill: parent + anchors.margins: Theme.space.sm + clip: true + boundsBehavior: Flickable.StopAtBounds + model: root.versions + ScrollBar.vertical: ScrollBar {} + + delegate: ItemDelegate { + id: cell + required property int index + required property string versionId + required property string version + required property string type + + width: list.width + height: Theme.control.heightLg + highlighted: ListView.isCurrentItem + + onClicked: list.currentIndex = index + onDoubleClicked: root.doChange(cell.versionId) + + contentItem: RowLayout { + spacing: Theme.space.sm + Text { + Layout.fillWidth: true + text: cell.version + color: Theme.palette.textPrimary + font.family: Theme.font.family + font.pixelSize: Theme.type.body.pixelSize + } + Tag { visible: cell.type !== "release"; text: cell.type } + } + } + } + + BusyIndicator { + anchors.centerIn: parent + visible: running + running: !!root.versions && root.versions.loading + } + + EmptyState { + anchors.centerIn: parent + visible: !!root.versions && !root.versions.loading && root.versions.count === 0 + title: qsTr("No versions found") + body: root.versions && root.versions.error.length > 0 + ? root.versions.error + : qsTr("Try turning on snapshots or old versions.") + MeshIcon { iconName: "cube"; size: 40; color: Theme.palette.textTertiary } + } + } + } + + footer: Row { + layoutDirection: Qt.RightToLeft + spacing: Theme.space.sm + padding: Theme.space.lg + topPadding: 0 + + Button { + text: qsTr("Change") + enabled: list.currentIndex >= 0 + onClicked: root.doChange(list.currentItem ? list.currentItem.versionId : "") + } + Button { + text: qsTr("Cancel") + flat: true + onClicked: root.close() + } + } + + function doChange(versionId) { + if (!root.details || !root.details.components || versionId.length === 0) { + return + } + if (root.details.components.changeComponentVersion("net.minecraft", versionId)) { + root.close() + } + } +} diff --git a/launcher/qml/Components/ModpackCard.qml b/launcher/qml/Components/ModpackCard.qml new file mode 100644 index 00000000..99048087 --- /dev/null +++ b/launcher/qml/Components/ModpackCard.qml @@ -0,0 +1,540 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import MeshMC.Theme + +/* + * One modpack in Discover's card grid: a cover image (or a tinted gradient + * with the project's own logo when it has no gallery shot), the logo + * overlapping the cover's foot, title/author, a two-line pitch, up to + * three categories and a downloads/updated footer. The whole card opens + * the pack's detail page -- nothing on it installs directly, since + * installing means picking a version first (see ModpackDetail.qml). + * + * `skeleton: true` swaps every field for a shimmering Skeleton block in + * the same geometry, for the loading grid DiscoverPage shows before a + * page of results comes back. That is also why every property below has + * a plain default rather than being `required`: a skeleton card is not + * bound to a model row at all. + */ +Item { + id: root + + property string projectId: "" + property string title: "" + property string author: "" + property string description: "" + property string logoUrl: "" + property var downloads: 0 + // Modrinth's "date_modified", ISO 8601 -- may be empty. + property string updated: "" + property var categories: [] + // Cover image URL; empty falls back to a tinted gradient. + property string galleryUrl: "" + // A colour, or undefined/null when Modrinth has none for this project + // -- see ModrinthModpackModel's accentColor role. + property var accentColor: undefined + property bool skeleton: false + + signal clicked() + + readonly property bool hovered: !root.skeleton && hoverHandler.hovered + readonly property color tint: root.accentColor ? root.accentColor : Theme.palette.accent + readonly property int coverHeight: Math.round(root.width * 0.56) + readonly property int logoSize: 56 + // How far the logo sinks into the cover; the rest of it hangs below, + // into the content area -- the "overlapping the cover's edge" look. + readonly property int logoOverlap: Math.round(root.logoSize * 0.5) + readonly property var shownCategories: (root.categories || []).slice(0, 3) + + function titleCase(word) { + return word.length > 0 ? word.charAt(0).toUpperCase() + word.slice(1) : word + } + + // Which of shownCategories actually fit the card's content width, plus + // how many were left out -- rather than always laying out all three and + // letting the card's own clip cut the last one off mid-word at the + // border. Category names vary a lot in length ("Magic" vs. + // "Optimization"), and the card grid's own column count changes the + // available width, so this is measured, not guessed at a fixed count. + // + // A plain property recomputed on the specific changes below, not a + // binding straight off computeTagFit(): that function both writes + // tagMetrics.text and reads the resulting tagMetrics.width, and a + // binding that reads a value it just wrote itself is exactly what + // "Binding loop detected" is warning about. + property var tagFit: ({ shown: [], hiddenCount: 0 }) + onWidthChanged: tagFit = computeTagFit() + onShownCategoriesChanged: tagFit = computeTagFit() + Component.onCompleted: tagFit = computeTagFit() + function computeTagFit() { + var cats = root.shownCategories.map(titleCase) + var maxWidth = Math.max(0, root.width - Theme.space.md * 2) + var spacing = Theme.space.xs + if (cats.length === 0) + return { shown: [], hiddenCount: 0 } + function chipWidth(text) { + tagMetrics.text = text + return tagMetrics.width + Theme.space.sm * 2 + } + var widths = cats.map(chipWidth) + var total = widths.reduce(function (a, b) { return a + b }, 0) + spacing * (cats.length - 1) + if (total <= maxWidth) + return { shown: cats, hiddenCount: 0 } + var shown = [] + var used = 0 + for (var i = 0; i < cats.length; ++i) { + var remaining = cats.length - i - 1 + var overflowW = remaining > 0 ? chipWidth("+" + remaining) + spacing : 0 + var withSpacing = shown.length > 0 ? spacing : 0 + if (used + withSpacing + widths[i] + overflowW > maxWidth) + break + used += withSpacing + widths[i] + shown.push(cats[i]) + } + return { shown: shown, hiddenCount: cats.length - shown.length } + } + + TextMetrics { + id: tagMetrics + font.family: Theme.font.family + font.pixelSize: Theme.type.caption.pixelSize + font.weight: Font.Medium + } + + // Modrinth's ISO date -> "3 h ago" / "5 d ago", the same coarseness + // Format.lastPlayed uses for an instance's last launch. Kept local + // rather than added there: a pack's "date_modified" has no "never" + // state to special-case, and Format.qml belongs to a different area. + function timeAgo(iso) { + if (!iso) + return "" + var ms = Date.parse(iso) + if (isNaN(ms)) + return "" + var minutes = Math.max(0, Math.floor((Date.now() - ms) / 60000)) + if (minutes < 60) + return qsTr("Updated just now") + var hours = Math.floor(minutes / 60) + if (hours < 24) + return qsTr("Updated %1 h ago").arg(hours) + var days = Math.floor(hours / 24) + if (days < 30) + return qsTr("Updated %1 d ago").arg(days) + var months = Math.floor(days / 30) + if (months < 12) + return qsTr("Updated %1 mo ago").arg(months) + return qsTr("Updated %1 y ago").arg(Math.floor(months / 12)) + } + + implicitWidth: 280 + implicitHeight: card.height + + activeFocusOnTab: !root.skeleton + Keys.onReturnPressed: root.clicked() + Keys.onSpacePressed: root.clicked() + + Accessible.role: Accessible.ListItem + Accessible.name: root.title + Accessible.description: root.description + + HoverHandler { id: hoverHandler; enabled: !root.skeleton } + TapHandler { + enabled: !root.skeleton + gesturePolicy: TapHandler.ReleaseWithinBounds + onTapped: { root.forceActiveFocus(); root.clicked() } + } + + Rectangle { + id: card + width: parent.width + height: bodyColumn.y + bodyColumn.height + Theme.space.md + radius: Theme.radius.lg + clip: true + // Hover changes only the border and fill -- no lift, no motion. + color: root.hovered ? Theme.palette.surfaceRaised : Theme.palette.surface + border.width: 1 + border.color: root.hovered ? Theme.palette.borderStrong : Theme.palette.border + + Behavior on color { ColorAnimation { duration: Theme.motion.fast; easing.type: Theme.motion.easing } } + Behavior on border.color { ColorAnimation { duration: Theme.motion.fast; easing.type: Theme.motion.easing } } + + // Cover -- edge to edge; see the corner mask at its end for how the + // top corners get rounded without a per-corner radius (Qt 6.7+). + Item { + id: cover + x: 0 + y: 0 + width: parent.width + height: root.coverHeight + clip: true + + Skeleton { anchors.fill: parent; radius: 0; visible: root.skeleton } + + // A gallery shot that has not arrived yet (Modrinth's images + // routinely take several seconds) gets a quiet shimmer, not the + // permanent fallback plate below -- that plate is for a pack + // that has no gallery shot at all, or whose one failed outright. + Skeleton { + anchors.fill: parent + radius: 0 + visible: !root.skeleton && root.galleryUrl.length > 0 && coverImage.status === Image.Loading + } + + // Permanent fallback for a pack with no gallery shot (or a + // failed one): a gradient cut from the project's own accent + // colour with a subtle blocky pattern, the same idiom + // CoverArt.qml uses for an instance with no screenshot -- + // never the pack's own small icon blown up, which just reads as + // blurry. + Rectangle { + id: fallback + anchors.fill: parent + visible: !root.skeleton + && (root.galleryUrl.length === 0 || coverImage.status === Image.Error) + gradient: Gradient { + GradientStop { position: 0.0; color: Format.shade(root.tint, Theme.dark ? 0.30 : 0.88, 0.9) } + GradientStop { position: 1.0; color: Format.shade(root.tint, Theme.dark ? 0.13 : 0.72, 0.85) } + } + + Item { + anchors.fill: parent + anchors.margins: -parent.width * 0.15 + clip: true + + Repeater { + model: 3 + delegate: Rectangle { + required property int index + readonly property real span: fallback.height * (1.35 - index * 0.3) + x: fallback.width - span * 0.62 + index * span * 0.20 + y: fallback.height * 0.30 - span * 0.5 + index * span * 0.16 + width: span + height: span + rotation: 18 + radius: Theme.radius.sm + color: Qt.rgba(1, 1, 1, Theme.dark ? 0.05 : 0.10) + } + } + } + } + + Image { + id: coverImage + anchors.fill: parent + source: root.skeleton ? "" : root.galleryUrl + sourceSize: Qt.size(640, 360) + fillMode: Image.PreserveAspectCrop + asynchronous: true + visible: !root.skeleton && root.galleryUrl.length > 0 + opacity: status === Image.Ready ? 1 : 0 + scale: root.hovered ? 1.06 : 1.0 + Behavior on opacity { NumberAnimation { duration: Theme.motion.normal } } + Behavior on scale { NumberAnimation { duration: Theme.motion.slow; easing.type: Theme.motion.easing } } + } + + // Scrim + "view" affordance, on hover only. + Rectangle { + anchors.fill: parent + visible: !root.skeleton + color: Theme.palette.scrim + opacity: root.hovered ? 0.35 : 0 + Behavior on opacity { NumberAnimation { duration: Theme.motion.fast } } + } + Rectangle { + visible: !root.skeleton + anchors.centerIn: parent + // A plain fade, no overshoot scale-in (design-plan.md §4/§2.4). + opacity: root.hovered ? 1 : 0 + radius: Theme.radius.pill + color: Theme.palette.surfaceOverlay + width: viewRow.implicitWidth + Theme.space.lg * 2 + height: Theme.control.height + Behavior on opacity { NumberAnimation { duration: Theme.motion.fast } } + + Row { + id: viewRow + anchors.centerIn: parent + spacing: Theme.space.xs + MeshIcon { + anchors.verticalCenter: parent.verticalCenter + iconName: "chevron-right" + size: Theme.icon.sm + color: Theme.palette.textPrimary + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: qsTr("View pack") + color: Theme.palette.textPrimary + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + font.weight: Font.DemiBold + } + } + } + + // Rectangle `clip` is square, so the cover would poke out past + // the card's rounded top corners (more so while it zooms on + // hover). A ring in the page colour covers exactly those two + // corners: its inner edge is the card's rounded outline, and it + // runs on below the cover so its lower corners are clipped away. + Rectangle { + readonly property int ring: card.radius + 2 + x: -ring + y: -ring + width: parent.width + ring * 2 + height: parent.height + ring * 2 + card.radius * 2 + radius: card.radius + ring + color: "transparent" + border.width: ring + border.color: Theme.palette.canvas + } + } + + // Header: the logo, half sunk into the cover, and title/author + // beside it. + Item { + id: header + anchors.top: cover.bottom + anchors.topMargin: -root.logoOverlap + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: Theme.space.md + anchors.rightMargin: Theme.space.md + // Tall enough for whichever reaches lower: the logo, or the + // title/author, which start below the cover's edge. + height: Math.max(root.logoSize, titleColumn.y + titleColumn.implicitHeight) + + Rectangle { + id: logoFrame + width: root.logoSize + height: root.logoSize + radius: Theme.radius.md + color: Theme.palette.surface + border.width: 2 + border.color: Theme.palette.surface + z: 2 + + Skeleton { anchors.fill: parent; anchors.margins: 2; radius: Theme.radius.md - 2; visible: root.skeleton } + + Rectangle { + anchors.fill: parent + anchors.margins: 2 + radius: Theme.radius.md - 2 + color: Theme.palette.surfaceSunken + clip: true + visible: !root.skeleton + + // A quiet shimmer while it loads, not the "no icon at + // all" glyph -- that one is for a pack with no logo URL, + // or one whose fetch failed outright. + Skeleton { + anchors.fill: parent + radius: 0 + visible: root.logoUrl.length > 0 && logoImg.status === Image.Loading + } + + Image { + id: logoImg + anchors.fill: parent + source: root.logoUrl + sourceSize: Qt.size(112, 112) + fillMode: Image.PreserveAspectCrop + asynchronous: true + visible: opacity > 0 + opacity: status === Image.Ready ? 1 : 0 + Behavior on opacity { NumberAnimation { duration: Theme.motion.normal } } + } + MeshIcon { + anchors.centerIn: parent + visible: root.logoUrl.length === 0 || logoImg.status === Image.Error + iconName: "package" + size: Theme.icon.md + color: Theme.palette.textTertiary + } + } + } + + // Below the cover, never over the picture: a bright gallery + // shot would swallow the title. + Column { + id: titleColumn + anchors.left: logoFrame.right + anchors.leftMargin: Theme.space.sm + anchors.right: parent.right + y: root.logoOverlap + Theme.space.xs + spacing: Theme.space.xxs + + // Wraps up to 2 lines rather than eliding a long title down + // to a handful of characters -- "Zombie Invade 100 Days" + // read as "Zombie Invade 100 D…" at this column's width. + // The height is fixed at exactly 2 lines regardless of how + // many the title actually needs, so a short title does not + // leave every other card in its grid row taller than it. + Text { + width: parent.width + height: Theme.type.title.lineHeightPx * 2 + visible: !root.skeleton + text: root.title + wrapMode: Text.Wrap + maximumLineCount: 2 + elide: Text.ElideRight + verticalAlignment: Text.AlignTop + color: Theme.palette.textPrimary + font.family: Theme.font.family + font.pixelSize: Theme.type.title.pixelSize + font.weight: Font.Bold + } + Skeleton { + width: parent.width * 0.62 + height: Theme.type.title.lineHeightPx * 2 + radius: Theme.radius.sm + visible: root.skeleton + } + + Text { + width: parent.width + visible: !root.skeleton && root.author.length > 0 + text: qsTr("by %1").arg(root.author) + elide: Text.ElideRight + color: Theme.palette.textTertiary + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + } + Skeleton { + width: parent.width * 0.4 + height: Theme.type.label.pixelSize + radius: Theme.radius.sm + visible: root.skeleton + } + } + } + + Column { + id: bodyColumn + anchors.top: header.bottom + anchors.topMargin: Theme.space.sm + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: Theme.space.md + anchors.rightMargin: Theme.space.md + spacing: Theme.space.sm + + Text { + width: parent.width + visible: !root.skeleton + text: root.description + wrapMode: Text.Wrap + maximumLineCount: 2 + elide: Text.ElideRight + color: Theme.palette.textSecondary + font.family: Theme.font.family + font.pixelSize: Theme.type.body.pixelSize + lineHeight: 1.3 + } + Column { + width: parent.width + spacing: 3 + visible: root.skeleton + Skeleton { width: parent.width; height: Theme.type.body.pixelSize; radius: Theme.radius.sm } + Skeleton { width: parent.width * 0.72; height: Theme.type.body.pixelSize; radius: Theme.radius.sm } + } + + Row { + spacing: Theme.space.xs + visible: !root.skeleton && root.shownCategories.length > 0 + Repeater { + model: root.tagFit.shown + delegate: Tag { text: modelData } + } + Tag { + visible: root.tagFit.hiddenCount > 0 + text: "+" + root.tagFit.hiddenCount + } + } + Row { + spacing: Theme.space.xs + visible: root.skeleton + Repeater { + model: 3 + delegate: Skeleton { width: 52; height: Theme.control.heightSm - Theme.space.xs; radius: Theme.radius.sm + 2 } + } + } + + Rectangle { + width: parent.width + height: 1 + color: Theme.palette.divider + visible: !root.skeleton + } + + Item { + width: parent.width + height: Theme.icon.sm + visible: !root.skeleton + + Row { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.space.xxs + MeshIcon { + anchors.verticalCenter: parent.verticalCenter + iconName: "download" + size: Theme.icon.sm + color: Theme.palette.textTertiary + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: Format.compactNumber(root.downloads) + color: Theme.palette.textSecondary + font.family: Theme.font.family + font.pixelSize: Theme.type.caption.pixelSize + font.weight: Font.Medium + } + } + Text { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + visible: root.updated.length > 0 + text: root.timeAgo(root.updated) + color: Theme.palette.textTertiary + font.family: Theme.font.family + font.pixelSize: Theme.type.caption.pixelSize + } + } + Item { + width: parent.width + height: Theme.type.caption.pixelSize + visible: root.skeleton + Skeleton { anchors.left: parent.left; width: 44; height: parent.height; radius: Theme.radius.sm } + Skeleton { anchors.right: parent.right; width: 60; height: parent.height; radius: Theme.radius.sm } + } + } + + // The card's border again, on top: the edge-to-edge cover paints + // over the card's own one along the top and sides. + Rectangle { + anchors.fill: parent + radius: card.radius + color: "transparent" + border.width: card.border.width + border.color: card.border.color + } + } + + Rectangle { + // Keyboard focus ring, outset so it never competes with the card's + // own border. + x: card.x - 3 + y: card.y - 3 + width: card.width + 6 + height: card.height + 6 + radius: card.radius + 3 + color: "transparent" + border.width: 2 + border.color: Theme.palette.focusRing + visible: root.activeFocus + } +} diff --git a/launcher/qml/Components/ModpackDetail.qml b/launcher/qml/Components/ModpackDetail.qml new file mode 100644 index 00000000..9a049c53 --- /dev/null +++ b/launcher/qml/Components/ModpackDetail.qml @@ -0,0 +1,426 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import MeshMC.Theme + +/* + * One modpack, opened from Discover: what it is, which version to install, + * and the install itself -- progress in place, then a way to the new + * instance. `pack` is the row that was opened (title, author, logo, + * galleryUrl, accentColor...); `detail` is the model's lazily loaded body, + * gallery and version list. + */ +Item { + id: root + + property var pack: ({}) + property var detail: null + property var model: null + // (projectId, versionId, name, group) -> TaskWatcher. The shell's, so + // the install is owned by C++ and survives leaving this page. + property var installer: null + // TaskWatcher of the install started from here, if any. + property var watcher: null + + signal backRequested() + signal showInstanceRequested(string id) + + readonly property var versions: detail && detail.versions ? detail.versions : [] + readonly property var gallery: detail && detail.gallery ? detail.gallery : [] + readonly property bool installing: !!watcher && watcher.running + readonly property bool installed: !!watcher && watcher.succeeded + + readonly property color tint: root.pack.accentColor ? root.pack.accentColor : Theme.palette.accent + // The best backdrop available right now: the detail's own gallery once + // it has loaded (its featured image first, see ModrinthModpackModel), + // falling back to the card's cover so the header is never bare while + // that request is still in flight. + readonly property string backdropUrl: root.gallery.length > 0 ? root.gallery[0].url + : (root.pack.galleryUrl || "") + + function versionLabel(v) { + var parts = [] + if (v.gameVersions && v.gameVersions.length > 0) + parts.push(v.gameVersions[0]) + if (v.loaders && v.loaders.length > 0) + parts.push(v.loaders.map(l => l.charAt(0).toUpperCase() + l.slice(1)).join(", ")) + return (v.versionNumber || v.name) + (parts.length > 0 ? " · " + parts.join(" · ") : "") + } + + // Modrinth's ISO date -> "3 h ago" / "5 d ago". Duplicated from + // ModpackCard rather than shared: both are a handful of lines, and + // Format.qml (the natural shared home) belongs to a different area. + function timeAgo(iso) { + if (!iso) + return "" + var ms = Date.parse(iso) + if (isNaN(ms)) + return "" + var minutes = Math.max(0, Math.floor((Date.now() - ms) / 60000)) + if (minutes < 60) + return qsTr("just now") + var hours = Math.floor(minutes / 60) + if (hours < 24) + return qsTr("%1 h ago").arg(hours) + var days = Math.floor(hours / 24) + if (days < 30) + return qsTr("%1 d ago").arg(days) + var months = Math.floor(days / 30) + if (months < 12) + return qsTr("%1 mo ago").arg(months) + return qsTr("%1 y ago").arg(Math.floor(months / 12)) + } + + function install() { + if (!root.installer || versionBox.currentIndex < 0) + return + var v = root.versions[versionBox.currentIndex] + root.watcher = root.installer(root.pack.projectId, v.id, + nameField.text.length > 0 ? nameField.text : root.pack.title, "") + } + + onPackChanged: { + watcher = null + nameField.text = pack.title || "" + } + + Flickable { + anchors.fill: parent + contentWidth: width + contentHeight: column.height + Theme.space.xxl + boundsBehavior: Flickable.StopAtBounds + clip: true + ScrollBar.vertical: ScrollBar {} + + Column { + id: column + x: Theme.space.xl + Theme.space.xs + width: Math.min(parent.width - x * 2, 960) + spacing: Theme.space.xl + + Button { + flat: true + text: qsTr("Back to results") + icon.source: Icons.url("chevron-left") + leftPadding: Theme.space.sm + onClicked: root.backRequested() + } + + // Cinematic header: the gallery's own hero shot (or a fallback + // gradient cut from the project's accent colour) behind a + // bottom scrim, the logo, title/author and the headline stats. + Rectangle { + id: hero + width: parent.width + height: Math.max(200, Math.min(320, Math.round(width * 0.32))) + radius: Theme.radius.xl + clip: true + color: Theme.palette.surfaceSunken + + Rectangle { + id: heroFallback + anchors.fill: parent + visible: backdrop.status !== Image.Ready + gradient: Gradient { + GradientStop { position: 0.0; color: Format.shade(root.tint, Theme.dark ? 0.30 : 0.88, 0.9) } + GradientStop { position: 1.0; color: Format.shade(root.tint, Theme.dark ? 0.12 : 0.70, 0.85) } + } + Image { + anchors.centerIn: parent + width: parent.height * 1.3 + height: width + source: root.pack.logoUrl || "" + sourceSize: Qt.size(120, 120) + fillMode: Image.PreserveAspectFit + asynchronous: true + opacity: 0.18 + visible: status === Image.Ready + } + } + + Image { + id: backdrop + anchors.fill: parent + source: root.backdropUrl + sourceSize: Qt.size(1024, 480) + fillMode: Image.PreserveAspectCrop + asynchronous: true + opacity: status === Image.Ready ? 1 : 0 + Behavior on opacity { NumberAnimation { duration: Theme.motion.slow } } + } + + // Bottom scrim so title/stats stay legible over any image. + Rectangle { + anchors.fill: parent + gradient: Gradient { + GradientStop { position: 0.0; color: Qt.rgba(0, 0, 0, 0) } + GradientStop { position: 0.55; color: Qt.rgba(0, 0, 0, 0.15) } + GradientStop { position: 1.0; color: Theme.palette.scrim } + } + } + + RowLayout { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: Theme.space.lg + spacing: Theme.space.lg + + // A slightly larger, translucent copy sitting behind + // the logo as a soft drop shadow -- layered rectangles + // standing in for a shader-based blur this Qt floor + // does not have. + Item { + Layout.preferredWidth: 88 + Layout.preferredHeight: 88 + + Rectangle { + x: 3; y: 4 + width: parent.width; height: parent.height + radius: Theme.radius.xl + color: Qt.rgba(0, 0, 0, 0.35) + } + Rectangle { + width: 88; height: 88 + radius: Theme.radius.xl + color: Theme.palette.surface + border.width: 3 + border.color: Theme.palette.surface + + Image { + id: logoImg + anchors.fill: parent + anchors.margins: 3 + source: root.pack.logoUrl || "" + sourceSize: Qt.size(176, 176) + fillMode: Image.PreserveAspectCrop + asynchronous: true + visible: status === Image.Ready + } + MeshIcon { + anchors.centerIn: parent + visible: logoImg.status !== Image.Ready + iconName: "package" + size: Theme.icon.lg + color: Theme.palette.textTertiary + } + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Theme.space.xs + + Text { + Layout.fillWidth: true + text: root.pack.title || "" + elide: Text.ElideRight + color: Qt.rgba(1, 1, 1, 1) + font.family: Theme.font.family + font.pixelSize: Theme.type.display.pixelSize + font.weight: Font.Bold + font.letterSpacing: -0.4 + } + Text { + Layout.fillWidth: true + visible: !!root.pack.author + text: qsTr("by %1").arg(root.pack.author || "") + elide: Text.ElideRight + color: Qt.rgba(1, 1, 1, 0.78) + font.family: Theme.font.family + font.pixelSize: Theme.type.body.pixelSize + } + Row { + Layout.topMargin: Theme.space.xxs + spacing: Theme.space.sm + Tag { + onMedia: true + iconName: "download" + text: qsTr("%1 downloads").arg(Format.compactNumber(root.pack.downloads)) + } + Tag { + onMedia: true + visible: (root.pack.follows || 0) > 0 + iconName: "users" + text: qsTr("%1 followers").arg(Format.compactNumber(root.pack.follows)) + } + Tag { + onMedia: true + visible: (root.pack.updated || "").length > 0 + iconName: "clock" + text: qsTr("Updated %1").arg(root.timeAgo(root.pack.updated)) + } + } + } + } + } + + // Gallery strip -- only once the detail fetch actually has one. + Flickable { + width: parent.width + height: root.gallery.length > 0 ? 96 : 0 + visible: root.gallery.length > 0 + contentWidth: galleryRow.width + contentHeight: height + boundsBehavior: Flickable.StopAtBounds + clip: true + + Row { + id: galleryRow + spacing: Theme.space.sm + Repeater { + model: root.gallery + delegate: Rectangle { + required property var modelData + width: 152 + height: 86 + radius: Theme.radius.md + color: Theme.palette.surfaceSunken + clip: true + Image { + anchors.fill: parent + source: modelData.url || "" + sourceSize: Qt.size(304, 172) + fillMode: Image.PreserveAspectCrop + asynchronous: true + } + } + } + } + } + + Text { + width: parent.width + visible: text.length > 0 + text: root.pack.description || "" + wrapMode: Text.Wrap + color: Theme.palette.textSecondary + font.family: Theme.font.family + font.pixelSize: Theme.type.body.pixelSize + 1 + lineHeight: 1.35 + } + + // The grid card only ever shows one chip; the full list lives + // here (design-plan.md §5/§6). + Flow { + width: parent.width + spacing: Theme.space.xs + visible: (root.pack.categories || []).length > 0 + Repeater { + model: root.pack.categories || [] + delegate: Tag { text: modelData.length > 0 ? modelData.charAt(0).toUpperCase() + modelData.slice(1) : modelData } + } + } + + // Install + SettingsGroup { + width: parent.width + title: qsTr("Install") + + SettingRow { + label: qsTr("Version") + description: root.detail && root.detail.loading ? qsTr("Loading versions…") + : root.versions.length === 0 ? qsTr("No versions found.") : "" + ComboBox { + id: versionBox + width: 320 + enabled: root.versions.length > 0 && !root.installing + model: root.versions.map(v => root.versionLabel(v)) + // Newest first from Modrinth, alphas included: start on + // the version the author features, if there is one. + onModelChanged: { + var featured = root.versions.findIndex(v => v.featured) + currentIndex = featured >= 0 ? featured : 0 + } + } + } + + SettingRow { + label: qsTr("Instance name") + TextField { + id: nameField + width: 320 + enabled: !root.installing + selectByMouse: true + } + } + + SettingRow { + label: root.installed ? qsTr("Installed") + : root.installing ? (root.watcher.status || qsTr("Installing…")) + : root.watcher && root.watcher.failed ? qsTr("Install failed") + : qsTr("Ready to install") + description: root.watcher && root.watcher.failed ? root.watcher.error + : root.installed ? qsTr("“%1” is in your library.").arg(nameField.text) : "" + + Row { + spacing: Theme.space.md + + LaunchProgressBar { + anchors.verticalCenter: parent.verticalCenter + width: 180 + visible: root.installing + progress: root.watcher ? root.watcher.progress : -1 + } + + Button { + visible: !root.installed + enabled: !root.installing && versionBox.currentIndex >= 0 + highlighted: true + implicitHeight: Theme.control.heightLg + text: root.installing ? qsTr("Installing…") : qsTr("Install") + icon.source: Icons.url("download") + onClicked: root.install() + } + + Button { + visible: root.installed + highlighted: true + implicitHeight: Theme.control.heightLg + text: qsTr("Show in library") + icon.source: Icons.url("library") + onClicked: root.showInstanceRequested(root.watcher.instanceId || "") + } + } + } + } + + // About + Column { + width: parent.width + spacing: Theme.space.md + visible: !!root.detail && ((root.detail.body || "").length > 0 || root.detail.loading) + + Text { + leftPadding: Theme.space.xs + text: qsTr("About") + color: Theme.palette.textPrimary + font.family: Theme.font.family + font.pixelSize: Theme.type.title.pixelSize + font.weight: Font.Bold + } + + BusyIndicator { + visible: !!root.detail && root.detail.loading + running: visible + } + + Text { + width: parent.width + text: root.detail ? (root.detail.body || "") : "" + textFormat: Text.MarkdownText + wrapMode: Text.Wrap + color: Theme.palette.textSecondary + linkColor: Theme.palette.accent + font.family: Theme.font.family + font.pixelSize: Theme.type.body.pixelSize + onLinkActivated: (link) => Qt.openUrlExternally(link) + } + } + } + } +} diff --git a/launcher/qml/Components/NavItem.qml b/launcher/qml/Components/NavItem.qml new file mode 100644 index 00000000..47ccaafb --- /dev/null +++ b/launcher/qml/Components/NavItem.qml @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import MeshMC.Theme + +/* + * One destination in the sidebar. The selected fill itself is a single + * shared pill SidebarNav slides between items (see its activeIndicator) -- + * this control only ever draws its own transient hover/press state, so the + * pill shows through untouched wherever it sits. + */ +AbstractButton { + id: control + + property string iconName + property string label + property bool selected: false + // Icon rail mode: centers the icon and drops the label, which shows as + // a tooltip instead. + property bool collapsed: false + + implicitHeight: Theme.control.height + Theme.space.xs + implicitWidth: 200 + hoverEnabled: true + focusPolicy: Qt.TabFocus + + Accessible.role: Accessible.PageTab + Accessible.name: label + + ToolTip.visible: control.collapsed && control.hovered + ToolTip.delay: 400 + ToolTip.text: control.label + + // Collapsed: a compact square hugging just the icon rather than the + // whole stretched row -- the same square SidebarNav's own activeIndicator + // uses for the selected item (see its own comment), so hover and + // selection read as the same shape in the rail. + readonly property int railSquare: 40 + + background: Rectangle { + width: control.collapsed ? control.railSquare : control.width + height: control.collapsed ? control.railSquare : control.height + x: control.collapsed ? (control.width - width) / 2 : 0 + y: control.collapsed ? (control.height - height) / 2 : 0 + radius: Theme.radius.md + color: control.down ? Theme.palette.pressedOverlay + : control.hovered ? Theme.palette.hoverOverlay : "transparent" + Behavior on color { ColorAnimation { duration: Theme.motion.fast; easing.type: Theme.motion.easing } } + + Rectangle { + anchors.fill: parent + anchors.margins: -Theme.space.xxs + radius: parent.radius + Theme.space.xxs + color: "transparent" + border.width: 2 + border.color: Theme.palette.focusRing + visible: control.visualFocus + } + } + + contentItem: Item { + Row { + id: iconRow + anchors.verticalCenter: parent.verticalCenter + anchors.left: control.collapsed ? undefined : parent.left + anchors.horizontalCenter: control.collapsed ? parent.horizontalCenter : undefined + leftPadding: control.collapsed ? 0 : Theme.space.md + spacing: Theme.space.md + + MeshIcon { + anchors.verticalCenter: parent.verticalCenter + iconName: control.iconName + size: Theme.icon.md + color: control.selected ? Theme.palette.accent + : control.hovered ? Theme.palette.textPrimary : Theme.palette.textSecondary + } + + Text { + anchors.verticalCenter: parent.verticalCenter + visible: !control.collapsed + text: control.label + color: control.selected || control.hovered ? Theme.palette.textPrimary : Theme.palette.textSecondary + font.family: Theme.font.family + font.pixelSize: Theme.type.body.pixelSize + font.weight: control.selected ? Font.DemiBold : Font.Medium + } + } + } +} diff --git a/launcher/qml/Components/NavSelectionIndicator.qml b/launcher/qml/Components/NavSelectionIndicator.qml new file mode 100644 index 00000000..4fa22cb0 --- /dev/null +++ b/launcher/qml/Components/NavSelectionIndicator.qml @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import MeshMC.Theme + +/* + * The rail/list-nav selection grammar (design-plan.md Principle 6): a + * surfaceRaised pill that slides between rows, plus a 3px accent bar pinned + * to the host's own leading edge -- both siblings of whatever draws the rows + * themselves (a NavItem only ever paints its own transient hover/press + * state; the pill is what actually shows "this one is selected"). + * + * Extracted out of SidebarNav.qml so every rail/list-nav host shares one + * mechanism instead of each re-deriving the same geometry: SidebarNav uses + * it for the main rail, and SettingsPage's section list uses it too, rather + * than falling back to NavItem's own bare accent-icon/bold-text look with no + * pill at all. + * + * A host places this as a sibling *behind* its row of NavItems (declared + * first, so it paints first) and points `target` at the currently selected + * row's Item -- this component only reads that item's y/height/width, it + * never owns the rows or their model. + */ +Item { + id: root + + // The selected row (a NavItem or equivalent), or null to hide entirely + // -- e.g. a page the host's own list doesn't contain. + property Item target: null + + // Icon-rail mode: a centred square matching the row's own collapsed + // square, rather than a bar stretched to the row's full width. Hosts + // that never collapse (Settings' section list) leave this false. + property bool collapsed: false + property int railSquare: 40 + + // Offset from this Item's own origin to where the hosted rows actually + // start -- e.g. SidebarNav's rows sit inside a ColumnLayout with + // `anchors.margins: Theme.space.md`, so the pill needs the same offset + // to land on them; a host with no such margin leaves these at 0. + property int insetX: 0 + property int insetY: 0 + + readonly property real targetY: root.target ? root.target.y : 0 + readonly property real targetH: root.target ? root.target.height : 0 + readonly property real targetW: root.target ? root.target.width : 0 + + Rectangle { + id: pill + visible: !!root.target + width: root.collapsed ? root.railSquare : root.targetW + height: root.collapsed ? root.railSquare : root.targetH + x: root.insetX + (root.collapsed ? (root.targetW - width) / 2 : 0) + y: root.insetY + root.targetY + (root.collapsed ? (root.targetH - height) / 2 : 0) + radius: Theme.radius.md + color: Theme.palette.surfaceRaised + + Behavior on x { NumberAnimation { duration: Theme.motion.normal; easing.type: Theme.motion.easing } } + Behavior on y { NumberAnimation { duration: Theme.motion.normal; easing.type: Theme.motion.easing } } + Behavior on width { NumberAnimation { duration: Theme.motion.normal; easing.type: Theme.motion.easing } } + } + + Rectangle { + visible: !!root.target + x: root.insetX + y: root.insetY + root.targetY + (root.targetH - height) / 2 + width: 3 + height: root.targetH - root.insetY * 2 + 4 + radius: Theme.radius.xs + color: Theme.palette.accent + + Behavior on y { NumberAnimation { duration: Theme.motion.normal; easing.type: Theme.motion.easing } } + } +} diff --git a/launcher/qml/Components/NewInstanceDialog.qml b/launcher/qml/Components/NewInstanceDialog.qml new file mode 100644 index 00000000..996dac8c --- /dev/null +++ b/launcher/qml/Components/NewInstanceDialog.qml @@ -0,0 +1,713 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import QtQuick.Dialogs +import QtQuick.Layouts +import MeshMC.Theme + +/* + * A new instance, in one of two modes: + * - "create": pick a Minecraft version and, if wanted, a mod loader; name + * it; create. + * - "import": a local .zip/.mrpack export (CurseForge, Modrinth, MultiMC, + * Prism...) or a direct download URL, staged through the same + * InstanceImportTask the widget dialog used. + * Modpacks browsed by project (Modrinth) come from Discover instead; other + * catalogue browsing (CurseForge/FTB/ATLauncher/Technic) is not built in + * QML yet -- see the note in the import pane. + * + * In create mode the name follows the chosen version ("1.21.4", "Fabric + * 1.21.4") until the user types one of their own; in import mode it follows + * the picked file/URL the same way. + */ +Dialog { + id: root + + // NewInstanceController; set when the dialog opens, so the version list + // is only fetched once someone actually wants a new instance. + property var controller: null + // TaskWatcher of the creation/import in progress. + property var watcher: null + property bool nameEdited: false + // IconList model for the icon picker below, or null to hide it (the + // integrator wires the shell's iconsModel in; without it the button + // still shows the default icon, just with nothing to pick from). + property var iconsModel: null + property string selectedIcon: "default" + + // "create" or "import" -- set by the integrator before open(), e.g. + // Main.qml's openNewInstance(mode). + property string mode: "create" + // Raw text of the chosen file/URL in import mode. + property string importSource: "" + + signal created() + + readonly property bool creating: !!watcher && watcher.running + // Each loader wears the logo the launcher already ships for it (the + // iconName column of knownModLoaders(), served by the instanceicon + // provider); the unmodified game gets the grass block, the default + // instance icon. + readonly property var loaders: [ + { value: "", label: qsTr("Vanilla"), iconKey: "grass" }, + { value: "fabric", label: "Fabric", iconKey: "fabricmc" }, + { value: "quilt", label: "Quilt", iconKey: "quiltmc" }, + { value: "forge", label: "Forge", iconKey: "forge" }, + { value: "neoforge", label: "NeoForge", iconKey: "neoforged" } + ] + + // Start on the newest version of the list shown, so Create works + // straight away; an explicit pick is never overridden. + function selectDefaultVersion() { + if (!root.controller || root.controller.selectedMinecraftVersion.length > 0) + return + var first = root.controller.minecraftVersions.firstVersionId + if (first && first.length > 0) + root.controller.selectMinecraftVersion(first) + } + + function refreshSuggestedName() { + if (!root.nameEdited && root.controller) + nameField.text = root.controller.suggestedName() + } + + function refreshImportName() { + if (root.nameEdited || !root.controller) + return + var suggested = root.controller.suggestedNameForImportSource(root.importSource) + if (suggested.length > 0) + nameField.text = suggested + } + + // Called from the URL field, the file picker and the drop area alike, + // so all three keep the field, the property and the suggested name in + // sync with each other regardless of which one changed. + function setImportSource(source) { + root.importSource = source + sourceField.text = source + refreshImportName() + } + + function typeLabel(type) { + switch (type) { + case "release": return qsTr("Release") + case "snapshot": return qsTr("Snapshot") + case "old_beta": return qsTr("Beta") + case "old_alpha": return qsTr("Alpha") + default: return type + } + } + + parent: Overlay.overlay + anchors.centerIn: parent + width: Math.min(860, parent ? parent.width - Theme.space.xxl * 2 : 860) + height: Math.min(640, parent ? parent.height - Theme.space.xxl * 2 : 640) + modal: true + closePolicy: root.creating ? Popup.NoAutoClose : Popup.CloseOnEscape + title: root.mode === "import" ? qsTr("Import instance") : qsTr("New instance") + + // A custom header rather than Style/Dialog.qml's plain title-only one + // (see DialogHeader.qml's own doc comment on when that is worth it): + // MeshMC's own mark, small and quiet, leading the title -- the second + // of the two places design-plan.md §1 reuses it as a recurring tell. + // The same quiet block-texture wash Settings/Discover carry (see + // AmbientPattern.qml), scaled down to a header: inset by the dialog's + // own corner radius and faded out at its sides and foot, so it never + // pokes past the rounded outline or ends on a hard edge. + header: Item { + implicitHeight: Theme.space.lg + Math.max(Theme.control.height, headerLabel.implicitHeight) + + AmbientPattern { + anchors.fill: parent + anchors.topMargin: 1 + anchors.leftMargin: Theme.radius.xl + anchors.rightMargin: Theme.radius.xl + fadeBottom: true + fadeStart: 0.25 + fadeSides: Theme.space.xl + fadeColor: Theme.palette.surfaceOverlay + } + + BrandMark { + id: headerMark + x: Theme.space.lg + anchors.verticalCenter: headerLabel.verticalCenter + size: Theme.control.height - 10 + } + + Label { + id: headerLabel + y: Theme.space.lg + anchors.left: headerMark.right + anchors.leftMargin: Theme.space.sm + anchors.right: parent.right + anchors.rightMargin: Theme.space.lg + text: root.title + elide: Label.ElideRight + font.pixelSize: Theme.type.title.pixelSize + font.weight: Theme.type.title.weight + } + } + + // root.mode itself is left alone here -- the integrator sets it right + // before open(), e.g. Main.qml's openNewInstance(mode). + onOpened: { + root.watcher = null + root.nameEdited = false + root.selectedIcon = "default" + groupField.text = "" + root.importSource = "" + sourceField.text = "" + selectDefaultVersion() + refreshSuggestedName() + } + + Connections { + target: root.controller ? root.controller.minecraftVersions : null + ignoreUnknownSignals: true + function onFirstVersionIdChanged() { root.selectDefaultVersion() } + } + + Connections { + target: root.controller + ignoreUnknownSignals: true + function onSelectedMinecraftVersionChanged() { root.refreshSuggestedName() } + function onSelectedLoaderVersionChanged() { root.refreshSuggestedName() } + function onLoaderChanged() { root.refreshSuggestedName() } + } + + Connections { + target: root.watcher + ignoreUnknownSignals: true + function onFinished(ok) { + if (ok) { + root.close() + root.created() + } + } + } + + contentItem: ColumnLayout { + spacing: Theme.space.lg + + SegmentedControl { + Layout.alignment: Qt.AlignLeft + enabled: !root.creating + options: [ + { value: "create", label: qsTr("Create") }, + { value: "import", label: qsTr("Import") } + ] + current: root.mode + onActivated: (value) => root.mode = value + } + + // Name and group + RowLayout { + Layout.fillWidth: true + spacing: Theme.space.md + + // A tile as tall as the labelled name field beside it. + AbstractButton { + id: iconButton + Layout.alignment: Qt.AlignBottom + Layout.preferredWidth: nameColumn.height + Layout.preferredHeight: nameColumn.height + enabled: !root.creating + hoverEnabled: true + Accessible.name: qsTr("Instance icon") + ToolTip.visible: hovered + ToolTip.text: qsTr("Change icon") + onClicked: iconPicker.open() + + background: Rectangle { + radius: Theme.radius.lg + color: iconButton.hovered ? Theme.palette.hoverOverlay : Theme.palette.surfaceRaised + border.width: 1 + border.color: iconButton.hovered ? Theme.palette.borderStrong : Theme.palette.border + } + contentItem: Item { + Image { + anchors.centerIn: parent + width: Math.round(iconButton.height * 0.62) + height: width + source: "image://instanceicon/" + root.selectedIcon + sourceSize: Qt.size(width, height) + fillMode: Image.PreserveAspectFit + } + Rectangle { + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: Theme.space.xxs + width: Theme.icon.md + height: width + radius: width / 2 + color: Theme.palette.surfaceOverlay + border.width: 1 + border.color: Theme.palette.border + MeshIcon { + anchors.centerIn: parent + iconName: "edit" + size: Theme.icon.sm - 4 + color: Theme.palette.textSecondary + } + } + } + } + + Column { + id: nameColumn + Layout.fillWidth: true + Layout.alignment: Qt.AlignBottom + spacing: Theme.space.xs + Text { + text: qsTr("Name") + color: Theme.palette.textSecondary + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + font.weight: Font.Medium + } + TextField { + id: nameField + width: parent.width + enabled: !root.creating + selectByMouse: true + onTextEdited: root.nameEdited = text.length > 0 + } + } + + Column { + Layout.preferredWidth: 220 + Layout.alignment: Qt.AlignBottom + spacing: Theme.space.xs + Text { + text: qsTr("Group") + color: Theme.palette.textSecondary + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + font.weight: Font.Medium + } + TextField { + id: groupField + width: parent.width + enabled: !root.creating + selectByMouse: true + placeholderText: qsTr("No group") + text: "" + } + } + + } + + IconPickerDialog { + id: iconPicker + iconsModel: root.iconsModel + current: root.selectedIcon + onPicked: (key) => root.selectedIcon = key + } + + StackLayout { + Layout.fillWidth: true + Layout.fillHeight: true + currentIndex: root.mode === "import" ? 1 : 0 + + // Create: pick a Minecraft version and, optionally, a mod loader. + ColumnLayout { + spacing: Theme.space.lg + + // Minecraft version + RowLayout { + Layout.fillWidth: true + spacing: Theme.space.lg + + Text { + Layout.fillWidth: true + text: qsTr("Minecraft version") + color: Theme.palette.textPrimary + font.family: Theme.font.family + font.pixelSize: Theme.type.title.pixelSize + font.weight: Font.Bold + } + Switch { + text: qsTr("Snapshots") + enabled: !!root.controller + checked: root.controller ? root.controller.minecraftVersions.showSnapshots : false + onToggled: root.controller.minecraftVersions.showSnapshots = checked + } + Switch { + text: qsTr("Old versions") + enabled: !!root.controller + checked: root.controller ? root.controller.minecraftVersions.showOldVersions : false + onToggled: root.controller.minecraftVersions.showOldVersions = checked + } + } + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + radius: Theme.radius.lg + color: Theme.palette.surfaceSunken + border.width: 1 + border.color: Theme.palette.border + + ListView { + id: versions + anchors.fill: parent + anchors.margins: Theme.space.xs + clip: true + enabled: !root.creating + boundsBehavior: Flickable.StopAtBounds + model: root.controller ? root.controller.minecraftVersions : null + ScrollBar.vertical: ScrollBar {} + + delegate: AbstractButton { + id: versionRow + required property string version + required property string type + required property bool recommended + required property var time + readonly property bool selected: !!root.controller && root.controller.selectedMinecraftVersion === version + + width: versions.width - Theme.space.md + height: Theme.control.height + 4 + hoverEnabled: true + onClicked: root.controller.selectMinecraftVersion(version) + + background: Rectangle { + radius: Theme.radius.md + color: versionRow.selected ? Theme.palette.accentSubtle + : versionRow.hovered ? Theme.palette.hoverOverlay : "transparent" + } + + contentItem: RowLayout { + spacing: Theme.space.md + Text { + Layout.leftMargin: Theme.space.md + Layout.preferredWidth: 140 + text: versionRow.version + color: versionRow.selected ? Theme.palette.accent : Theme.palette.textPrimary + font.family: Theme.font.family + font.pixelSize: Theme.type.body.pixelSize + font.weight: versionRow.selected ? Font.Bold : Font.Medium + } + StatusBadge { + tone: versionRow.type === "release" ? "success" + : versionRow.type === "snapshot" ? "warning" : "neutral" + text: root.typeLabel(versionRow.type) + } + StatusBadge { + visible: versionRow.recommended + tone: "info" + text: qsTr("Recommended") + } + Item { Layout.fillWidth: true } + Text { + Layout.rightMargin: Theme.space.md + text: versionRow.time ? Qt.formatDate(versionRow.time, Qt.locale().dateFormat(Locale.ShortFormat)) : "" + color: Theme.palette.textTertiary + font.family: Theme.font.family + font.pixelSize: Theme.type.caption.pixelSize + } + } + } + } + + BusyIndicator { + anchors.centerIn: parent + running: !!root.controller && root.controller.minecraftVersions.loading + visible: running + } + Text { + anchors.centerIn: parent + width: parent.width - Theme.space.xxl * 2 + horizontalAlignment: Text.AlignHCenter + visible: !!root.controller && root.controller.minecraftVersions.error.length > 0 + text: root.controller ? root.controller.minecraftVersions.error : "" + wrapMode: Text.Wrap + color: Theme.palette.danger + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + } + } + + // Mod loader + ColumnLayout { + Layout.fillWidth: true + spacing: Theme.space.sm + + RowLayout { + Layout.fillWidth: true + spacing: Theme.space.md + + Text { + text: qsTr("Mod loader") + color: Theme.palette.textPrimary + font.family: Theme.font.family + font.pixelSize: Theme.type.title.pixelSize + font.weight: Font.Bold + } + Item { Layout.fillWidth: true } + ComboBox { + id: loaderVersionBox + Layout.preferredWidth: 220 + visible: !!root.controller && root.controller.loader.length > 0 + enabled: !root.creating && count > 0 + model: root.controller ? root.controller.loaderVersions : null + textRole: "version" + valueRole: "versionId" + displayText: root.controller && root.controller.loaderLoading ? qsTr("Loading\u2026") + : count === 0 ? qsTr("None for this version") + : currentIndex >= 0 ? currentText + : root.controller ? root.controller.selectedLoaderVersion : "" + // The controller may have picked a version before the rows + // reached this box; line the two up whenever either moves. + function syncToController() { + if (root.controller) + currentIndex = indexOfValue(root.controller.selectedLoaderVersion) + } + onCountChanged: syncToController() + onActivated: root.controller.selectLoaderVersion(currentValue) + // Follow the controller's pick (it defaults to the newest). + Connections { + target: root.controller + ignoreUnknownSignals: true + function onSelectedLoaderVersionChanged() { loaderVersionBox.syncToController() } + } + } + } + + // One selectable card per loader instead of a segmented control, + // so the showpiece dialog leads with imagery here too. + RowLayout { + Layout.fillWidth: true + spacing: Theme.space.sm + + Repeater { + model: root.loaders + + delegate: AbstractButton { + id: loaderCard + required property var modelData + readonly property bool selected: (root.controller ? root.controller.loader : "") === modelData.value + + Layout.fillWidth: true + Layout.preferredHeight: 92 + enabled: !root.creating + hoverEnabled: true + checkable: true + checked: selected + Accessible.name: modelData.label + onClicked: if (root.controller) root.controller.loader = modelData.value + + background: Rectangle { + radius: Theme.radius.lg + color: loaderCard.selected ? Theme.palette.accentSubtle + : loaderCard.hovered ? Theme.palette.hoverOverlay : Theme.palette.surfaceRaised + border.width: loaderCard.selected ? 2 : 1 + border.color: loaderCard.selected ? Theme.palette.accent : Theme.palette.border + Behavior on color { ColorAnimation { duration: Theme.motion.fast; easing.type: Theme.motion.easing } } + } + + contentItem: Item { + Column { + anchors.centerIn: parent + spacing: Theme.space.xs + + Image { + anchors.horizontalCenter: parent.horizontalCenter + width: 40 + height: width + source: "image://instanceicon/" + loaderCard.modelData.iconKey + sourceSize: Qt.size(width, height) + fillMode: Image.PreserveAspectFit + smooth: true + mipmap: true + opacity: loaderCard.selected || loaderCard.hovered ? 1 : 0.85 + } + + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: loaderCard.modelData.label + color: loaderCard.selected ? Theme.palette.textPrimary : Theme.palette.textSecondary + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + font.weight: loaderCard.selected ? Font.DemiBold : Font.Medium + } + } + } + } + } + } + } + } // create pane + + // Import: a local archive/export, or a direct download URL. + ColumnLayout { + spacing: Theme.space.lg + + Rectangle { + id: dropTarget + Layout.fillWidth: true + Layout.fillHeight: true + radius: Theme.radius.lg + color: Theme.palette.surfaceSunken + border.width: dropArea.containsDrag ? 2 : 1 + border.color: dropArea.containsDrag ? Theme.palette.accent : Theme.palette.border + Behavior on border.color { ColorAnimation { duration: Theme.motion.fast } } + + DropArea { + id: dropArea + anchors.fill: parent + enabled: !root.creating + onDropped: (drop) => { + if (drop.urls && drop.urls.length > 0) + root.setImportSource(drop.urls[0].toString()) + } + } + + ColumnLayout { + anchors.centerIn: parent + width: Math.min(460, dropTarget.width - Theme.space.xxl * 2) + spacing: Theme.space.md + + MeshIcon { + Layout.alignment: Qt.AlignHCenter + iconName: "package" + size: Theme.icon.lg + Theme.space.md + color: Theme.palette.textTertiary + } + + Text { + Layout.alignment: Qt.AlignHCenter + text: qsTr("Drop a .zip or .mrpack file here") + color: Theme.palette.textSecondary + font.family: Theme.font.family + font.pixelSize: Theme.type.body.pixelSize + } + + RowLayout { + Layout.fillWidth: true + spacing: Theme.space.sm + + TextField { + id: sourceField + Layout.fillWidth: true + enabled: !root.creating + selectByMouse: true + placeholderText: qsTr("Or paste a direct download link (https://…)") + onTextEdited: { + root.importSource = text + root.refreshImportName() + } + } + Button { + flat: true + enabled: !root.creating + text: qsTr("Browse…") + icon.source: Icons.url("folder") + onClicked: importFileDialog.open() + } + } + + Text { + Layout.fillWidth: true + wrapMode: Text.Wrap + visible: !root.creating && root.importSource.trim().length > 0 + && !!root.controller + && !root.controller.isImportSourceValid(root.importSource.trim()) + text: qsTr("Doesn't look like a modpack file or link yet -- pick a .zip/.mrpack/.jar that exists, or paste a direct download URL.") + color: Theme.palette.danger + font.family: Theme.font.family + font.pixelSize: Theme.type.caption.pixelSize + } + } + } + + Text { + Layout.fillWidth: true + wrapMode: Text.Wrap + text: qsTr("Packs from CurseForge, FTB, ATLauncher and Technic can be imported the same way, from their exported .zip file -- browsing those sites here is coming later.") + color: Theme.palette.textTertiary + font.family: Theme.font.family + font.pixelSize: Theme.type.caption.pixelSize + } + } // import pane + } // StackLayout + + FileDialog { + id: importFileDialog + title: qsTr("Choose an instance to import") + nameFilters: [qsTr("Modpack archives (*.zip *.mrpack)"), qsTr("All files (*)")] + onAccepted: root.setImportSource(selectedFile.toString()) + } + } + + footer: RowLayout { + spacing: Theme.space.sm + + Column { + Layout.leftMargin: Theme.space.lg + Layout.bottomMargin: Theme.space.lg + Layout.fillWidth: true + visible: root.creating || (!!root.watcher && root.watcher.failed) + spacing: Theme.space.xs + Text { + width: parent.width + text: root.watcher && root.watcher.failed ? (root.watcher.error || + (root.mode === "import" ? qsTr("Importing the instance failed.") : qsTr("Creating the instance failed."))) + : root.watcher ? (root.watcher.status || + (root.mode === "import" ? qsTr("Importing…") : qsTr("Creating…"))) : "" + elide: Text.ElideRight + color: root.watcher && root.watcher.failed ? Theme.palette.danger : Theme.palette.textSecondary + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + } + LaunchProgressBar { + width: Math.min(parent.width, 320) + visible: root.creating + progress: root.watcher ? root.watcher.progress : -1 + } + } + + Item { Layout.fillWidth: true; Layout.leftMargin: Theme.space.lg; visible: !root.creating && !(root.watcher && root.watcher.failed) } + + Button { + Layout.bottomMargin: Theme.space.lg + flat: true + text: qsTr("Cancel") + enabled: !root.creating + onClicked: root.close() + } + Button { + Layout.rightMargin: Theme.space.lg + Layout.bottomMargin: Theme.space.lg + highlighted: true + visible: root.mode !== "import" + text: root.creating ? qsTr("Creating…") : qsTr("Create") + icon.source: Icons.url("plus") + enabled: !root.creating && !!root.controller + && root.controller.selectedMinecraftVersion.length > 0 + && nameField.text.trim().length > 0 + onClicked: { + root.watcher = root.controller.create(nameField.text.trim(), groupField.text.trim(), root.selectedIcon) + } + } + Button { + Layout.rightMargin: Theme.space.lg + Layout.bottomMargin: Theme.space.lg + highlighted: true + visible: root.mode === "import" + text: root.creating ? qsTr("Importing…") : qsTr("Import") + icon.source: Icons.url("download") + enabled: !root.creating && !!root.controller + && root.importSource.trim().length > 0 + && nameField.text.trim().length > 0 + && root.controller.isImportSourceValid(root.importSource.trim()) + onClicked: { + root.watcher = root.controller.importFrom(root.importSource.trim(), nameField.text.trim(), groupField.text.trim(), root.selectedIcon) + } + } + } +} diff --git a/launcher/qml/Components/OnboardingView.qml b/launcher/qml/Components/OnboardingView.qml new file mode 100644 index 00000000..1286d33c --- /dev/null +++ b/launcher/qml/Components/OnboardingView.qml @@ -0,0 +1,458 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import MeshMC.Theme + +/* + * First run: the few choices MeshMC cannot make on its own -- language, + * which Java and how much memory, an account -- as one calm, full-window + * flow instead of a wizard dialog. Only the steps still needed are shown + * (the shell decides which, with the same rules the classic wizard had); + * the account step appears when there is no account yet and can be left + * for later. + */ +Rectangle { + id: root + + property var shell: null + // Shown steps, fixed when the flow opens so it does not reshuffle as + // each choice is saved. + property var steps: [] + property int index: 0 + readonly property string step: steps.length > 0 ? steps[Math.min(index, steps.length - 1)] : "" + property bool active: false + + signal finished() + signal signInRequested() + + function start() { + var needed = root.shell && root.shell.setupSteps ? root.shell.setupSteps.slice() : [] + if (needed.length === 0) + return + if (root.shell.accountCount === 0) + needed.push("account") + needed.push("done") + root.steps = needed + root.index = 0 + root.active = true + if (needed.indexOf("java") >= 0) + root.shell.detectJava() + } + + function next() { + if (root.step === "language" || root.step === "java") + root.shell.finishSetupStep(root.step) + if (root.index < root.steps.length - 1) + root.index++ + else + finish() + } + + function finish() { + root.active = false + root.finished() + } + + function stepTitle(id) { + switch (id) { + case "language": return qsTr("Language") + case "java": return qsTr("Java & memory") + case "account": return qsTr("Account") + case "done": return qsTr("Ready") + default: return id + } + } + + anchors.fill: parent + z: 800 + visible: active + color: Theme.palette.canvas + + // Swallow everything underneath while the flow is open. + MouseArea { anchors.fill: parent; acceptedButtons: Qt.AllButtons } + + RowLayout { + anchors.fill: parent + spacing: 0 + + // Brand side: who we are and where we are in the flow. + Rectangle { + Layout.fillHeight: true + Layout.preferredWidth: Math.min(380, root.width * 0.34) + gradient: Gradient { + GradientStop { position: 0.0; color: Qt.rgba(0, 0.9, 1, Theme.dark ? 0.16 : 0.22) } + GradientStop { position: 0.55; color: Theme.palette.surface } + GradientStop { position: 1.0; color: Qt.rgba(1, 0, 0.24, Theme.dark ? 0.12 : 0.14) } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: Theme.space.xxl + spacing: Theme.space.lg + + Image { + Layout.preferredWidth: 64 + Layout.preferredHeight: 64 + source: "qrc:/icons/multimc/scalable/instances/meshmc.svg" + sourceSize: Qt.size(128, 128) + fillMode: Image.PreserveAspectFit + } + Text { + Layout.fillWidth: true + text: qsTr("Welcome to MeshMC") + wrapMode: Text.Wrap + color: Theme.palette.textPrimary + font.family: Theme.font.family + font.pixelSize: Theme.type.display.pixelSize + 4 + font.weight: Font.Bold + font.letterSpacing: -0.6 + } + Text { + Layout.fillWidth: true + text: qsTr("A couple of choices and you're playing.") + wrapMode: Text.Wrap + color: Theme.palette.textSecondary + font.family: Theme.font.family + font.pixelSize: Theme.type.body.pixelSize + 1 + } + + Column { + Layout.topMargin: Theme.space.xl + spacing: Theme.space.md + Repeater { + model: root.steps + delegate: Row { + required property string modelData + required property int index + readonly property bool current: index === root.index + readonly property bool doneStep: index < root.index + spacing: Theme.space.md + + Rectangle { + width: 28; height: 28; radius: 14 + color: parent.current ? Theme.palette.accent + : parent.doneStep ? Theme.palette.accentSubtle : "transparent" + border.width: parent.current || parent.doneStep ? 0 : 1 + border.color: Theme.palette.borderStrong + Text { + anchors.centerIn: parent + visible: !parent.parent.doneStep + text: parent.parent.index + 1 + color: parent.parent.current ? Theme.palette.textOnAccent : Theme.palette.textTertiary + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + font.weight: Font.Bold + } + MeshIcon { + anchors.centerIn: parent + visible: parent.parent.doneStep + iconName: "check" + size: Theme.icon.sm + color: Theme.palette.accent + } + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: root.stepTitle(modelData) + color: parent.current ? Theme.palette.textPrimary : Theme.palette.textTertiary + font.family: Theme.font.family + font.pixelSize: Theme.type.body.pixelSize + font.weight: parent.current ? Font.DemiBold : Font.Medium + } + } + } + } + + Item { Layout.fillHeight: true } + } + } + + // The step itself. + Item { + Layout.fillWidth: true + Layout.fillHeight: true + + ColumnLayout { + anchors.fill: parent + anchors.margins: Theme.space.xxl + anchors.topMargin: Theme.space.xxl + Theme.space.lg + spacing: Theme.space.lg + + Text { + text: root.step === "language" ? qsTr("Choose your language") + : root.step === "java" ? qsTr("Java and memory") + : root.step === "account" ? qsTr("Sign in to play online") + : qsTr("You're all set") + color: Theme.palette.textPrimary + font.family: Theme.font.family + font.pixelSize: Theme.type.display.pixelSize + font.weight: Font.Bold + } + Text { + Layout.fillWidth: true + Layout.maximumWidth: 640 + wrapMode: Text.Wrap + text: root.step === "language" ? qsTr("MeshMC switches as soon as you pick one. You can change it later in Settings.") + : root.step === "java" ? qsTr("Minecraft runs on Java. Pick an installed one, or let MeshMC download the right version for each game automatically.") + : root.step === "account" ? qsTr("Use the Microsoft account that owns Minecraft. You can also do this later from the sidebar.") + : qsTr("Create an instance, or find a modpack in Discover.") + color: Theme.palette.textSecondary + font.family: Theme.font.family + font.pixelSize: Theme.type.body.pixelSize + 1 + lineHeight: 1.3 + } + + StackLayout { + Layout.fillWidth: true + Layout.fillHeight: true + Layout.maximumWidth: 720 + currentIndex: ["language", "java", "account", "done"].indexOf(root.step) + + // Language + ColumnLayout { + spacing: Theme.space.md + SearchBox { + id: languageSearch + Layout.fillWidth: true + placeholderText: qsTr("Search languages") + } + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + radius: Theme.radius.lg + color: Theme.palette.surface + border.width: 1 + border.color: Theme.palette.border + ListView { + id: languageList + anchors.fill: parent + anchors.margins: Theme.space.xs + clip: true + model: root.shell ? root.shell.languages : null + ScrollBar.vertical: ScrollBar {} + delegate: AbstractButton { + id: languageRow + required property string languageKey + required property string name + required property var completeness + readonly property bool matches: languageSearch.text.length === 0 + || name.toLowerCase().indexOf(languageSearch.text.toLowerCase()) >= 0 + || languageKey.toLowerCase().indexOf(languageSearch.text.toLowerCase()) >= 0 + readonly property bool chosen: SettingsStore.string("Language") === languageKey + width: languageList.width - Theme.space.md + height: matches ? Theme.control.heightLg : 0 + visible: matches + hoverEnabled: true + onClicked: root.shell.selectLanguage(languageKey) + background: Rectangle { + radius: Theme.radius.md + color: languageRow.chosen ? Theme.palette.accentSubtle + : languageRow.hovered ? Theme.palette.hoverOverlay : "transparent" + } + contentItem: RowLayout { + spacing: Theme.space.md + Text { + Layout.leftMargin: Theme.space.md + Layout.fillWidth: true + text: languageRow.name + // One edge for every name, right-to-left scripts too. + horizontalAlignment: Text.AlignLeft + elide: Text.ElideRight + color: languageRow.chosen ? Theme.palette.accent : Theme.palette.textPrimary + font.family: Theme.font.family + font.pixelSize: Theme.type.body.pixelSize + font.weight: languageRow.chosen ? Font.DemiBold : Font.Normal + } + Text { + Layout.rightMargin: Theme.space.md + visible: Number(languageRow.completeness) > 0 && Number(languageRow.completeness) < 1 + text: qsTr("%1% translated").arg(Math.round(Number(languageRow.completeness) * 100)) + color: Theme.palette.textTertiary + font.family: Theme.font.family + font.pixelSize: Theme.type.caption.pixelSize + } + } + } + } + } + } + + // Java and memory + ColumnLayout { + spacing: Theme.space.md + SettingsGroup { + Layout.fillWidth: true + SettingSwitch { + key: "JavaAutoDownload" + label: qsTr("Download Java automatically") + description: qsTr("Recommended. Each Minecraft version gets the Java it needs.") + } + MemorySetting { + label: qsTr("Maximum memory") + hint: qsTr("How much memory games may use.") + systemMiB: root.shell && root.shell.systemMemoryMiB ? root.shell.systemMemoryMiB : 8192 + } + } + RowLayout { + Layout.fillWidth: true + Text { + Layout.fillWidth: true + text: qsTr("Java on this computer") + color: Theme.palette.textPrimary + font.family: Theme.font.family + font.pixelSize: Theme.type.title.pixelSize + font.weight: Font.Bold + } + Button { + flat: true + text: qsTr("Detect again") + icon.source: Icons.url("refresh") + enabled: !!root.shell && !root.shell.javaDetecting + onClicked: root.shell.detectJava() + } + } + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + radius: Theme.radius.lg + color: Theme.palette.surface + border.width: 1 + border.color: Theme.palette.border + ListView { + id: javaList + anchors.fill: parent + anchors.margins: Theme.space.xs + clip: true + model: root.shell ? root.shell.javaInstalls : null + ScrollBar.vertical: ScrollBar {} + delegate: AbstractButton { + id: javaRow + required property string path + required property var version + required property var architecture + required property bool recommended + readonly property bool chosen: SettingsStore.string("JavaPath") === path + width: javaList.width - Theme.space.md + height: 52 + hoverEnabled: true + onClicked: root.shell.useJava(path) + background: Rectangle { + radius: Theme.radius.md + color: javaRow.chosen ? Theme.palette.accentSubtle + : javaRow.hovered ? Theme.palette.hoverOverlay : "transparent" + } + contentItem: RowLayout { + spacing: Theme.space.md + Column { + Layout.leftMargin: Theme.space.md + Layout.fillWidth: true + spacing: 2 + Text { + text: qsTr("Java %1").arg(String(javaRow.version)) + color: javaRow.chosen ? Theme.palette.accent : Theme.palette.textPrimary + font.family: Theme.font.family + font.pixelSize: Theme.type.body.pixelSize + font.weight: Font.DemiBold + } + Text { + width: parent.width + text: javaRow.path + elide: Text.ElideMiddle + color: Theme.palette.textTertiary + font.family: Theme.font.mono + font.pixelSize: Theme.type.caption.pixelSize + } + } + Tag { text: String(javaRow.architecture) } + StatusBadge { + Layout.rightMargin: Theme.space.md + visible: javaRow.recommended + tone: "success" + text: qsTr("Recommended") + } + } + } + BusyIndicator { + anchors.centerIn: parent + running: !!root.shell && root.shell.javaDetecting + visible: running + } + Text { + anchors.centerIn: parent + width: parent.width - Theme.space.xxl * 2 + visible: javaList.count === 0 && !!root.shell && !root.shell.javaDetecting + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.Wrap + text: SettingsStore.bool("JavaAutoDownload") + ? qsTr("No Java found on this computer. That's fine: MeshMC downloads the right one the first time you play.") + : qsTr("No Java found on this computer. Turn on automatic downloads above, or install Java and detect again.") + color: Theme.palette.textTertiary + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + lineHeight: 1.3 + } + } + } + } + + // Account + ColumnLayout { + spacing: Theme.space.md + Button { + highlighted: true + text: qsTr("Sign in with Microsoft") + icon.source: Icons.url("user") + onClicked: { + root.finish() + root.signInRequested() + } + } + Item { Layout.fillHeight: true } + } + + // Done + ColumnLayout { + spacing: Theme.space.md + MeshIcon { + iconName: "check" + size: 56 + color: Theme.palette.success + } + Item { Layout.fillHeight: true } + } + } + + RowLayout { + Layout.fillWidth: true + Layout.maximumWidth: 720 + spacing: Theme.space.sm + Button { + flat: true + visible: root.index > 0 && root.step !== "done" + text: qsTr("Back") + icon.source: Icons.url("chevron-left") + onClicked: root.index-- + } + Item { Layout.fillWidth: true } + Button { + flat: true + visible: root.step === "account" + text: qsTr("Later") + onClicked: root.next() + } + Button { + highlighted: true + visible: root.step !== "account" + text: root.step === "done" ? qsTr("Start playing") : qsTr("Continue") + icon.source: root.step === "done" ? Icons.url("play") : "" + onClicked: root.next() + } + } + } + } + } +} diff --git a/launcher/qml/Components/OverrideGroup.qml b/launcher/qml/Components/OverrideGroup.qml new file mode 100644 index 00000000..9f39bc7c --- /dev/null +++ b/launcher/qml/Components/OverrideGroup.qml @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick + +/* + * A group of instance settings that follow the launcher-wide ones until the + * instance asks for its own. The first row is that choice; the rows the + * caller adds below show the value in effect either way and should be + * `enabled: group.overriding`, so they only become editable once the + * instance overrides them. Turning the override off drops the instance's + * values, so it follows the launcher again -- the classic page did the same + * on save. + */ +SettingsGroup { + id: root + + property var source + property string gateKey + // The keys this group overrides, reset when the override is turned off. + property var keys: [] + property string gateLabel: qsTr("Use custom settings for this instance") + readonly property bool overriding: gate.checked + + SettingSwitch { + id: gate + source: root.source + key: root.gateKey + label: root.gateLabel + onSwitched: (on) => { + if (on) + return + for (var i = 0; i < root.keys.length; ++i) + root.source.reset(root.keys[i]) + } + } +} diff --git a/launcher/qml/Components/PackList.qml b/launcher/qml/Components/PackList.qml new file mode 100644 index 00000000..a0749fd4 --- /dev/null +++ b/launcher/qml/Components/PackList.qml @@ -0,0 +1,138 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import MeshMC.Theme + +/* + * One row per installed file, reused by ContentTab for whichever kind is + * selected (mods, resource packs, shader packs, texture packs) -- they are + * all ModFolderModel underneath with the same name/version/enabled roles, + * so a single delegate does for all of them. `kind` only changes the icon + * shown (there is no thumbnail data to fall back to) and the noun used + * when confirming a delete. + */ +Item { + id: root + + // InstanceDetails, and which of its folder-backed lists this shows. + property var details: null + property string kind: "mods" + property var model: null + property bool unlocked: false + property string iconName: "package" + + readonly property int count: list.count + + ListView { + id: list + anchors.fill: parent + clip: true + spacing: Theme.space.xs + boundsBehavior: Flickable.StopAtBounds + model: root.model + ScrollBar.vertical: ScrollBar {} + + delegate: Rectangle { + id: row + required property int index + // Through `model`: one of the roles is called "enabled", which + // as a delegate property would disable the row itself. + required property var model + readonly property string name: model.name + readonly property var version: model.version + readonly property bool itemEnabled: model.enabled + + width: list.width - Theme.space.md + height: Theme.control.heightLg + Theme.space.md + radius: Theme.radius.md + color: hover.hovered ? Theme.palette.surfaceRaised : Theme.palette.surface + border.width: 1 + border.color: Theme.palette.border + + Behavior on color { ColorAnimation { duration: Theme.motion.fast } } + + HoverHandler { id: hover } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: Theme.space.sm + anchors.rightMargin: Theme.space.sm + spacing: Theme.space.md + + Switch { + checked: row.itemEnabled + enabled: root.unlocked + Accessible.name: qsTr("Enable %1").arg(row.name) + onToggled: { + root.details.setEnabled(root.kind, row.index, checked) + checked = Qt.binding(() => row.itemEnabled) + } + } + + // Every kind here is a plain file/folder with no artwork of + // its own, so the row gets a consistent glyph tile instead + // of leaving an empty gap where a thumbnail would go. + Rectangle { + Layout.preferredWidth: 36 + Layout.preferredHeight: 36 + radius: Theme.radius.md + color: Theme.palette.surfaceSunken + opacity: row.itemEnabled ? 1 : Theme.opacity.disabled + MeshIcon { + anchors.centerIn: parent + iconName: root.iconName + size: Theme.icon.sm + color: Theme.palette.textTertiary + } + } + + Column { + Layout.fillWidth: true + Layout.minimumWidth: 0 + spacing: 1 + Text { + width: parent.width + text: row.name + elide: Text.ElideRight + color: row.itemEnabled ? Theme.palette.textPrimary : Theme.palette.textTertiary + font.family: Theme.font.family + font.pixelSize: Theme.type.body.pixelSize + font.weight: Font.Medium + } + Text { + width: parent.width + visible: text.length > 0 + text: row.version ? String(row.version) : "" + elide: Text.ElideRight + color: Theme.palette.textTertiary + font.family: Theme.font.family + font.pixelSize: Theme.type.caption.pixelSize + } + } + + IconButton { + visible: hover.hovered && root.unlocked + iconName: "trash" + tip: qsTr("Remove") + onClicked: { + confirm.row = row.index + confirm.text = qsTr("Remove “%1” from this instance? The file is deleted.").arg(row.name) + confirm.open() + } + } + } + } + } + + ConfirmDialog { + id: confirm + property int row: -1 + title: qsTr("Remove") + confirmText: qsTr("Remove") + onConfirmed: if (root.details) root.details.remove(root.kind, row) + } +} diff --git a/launcher/qml/Components/PageBackdrop.qml b/launcher/qml/Components/PageBackdrop.qml new file mode 100644 index 00000000..ed68f175 --- /dev/null +++ b/launcher/qml/Components/PageBackdrop.qml @@ -0,0 +1,95 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import MeshMC.Theme + +/* + * A page-level wash of one instance's own cover art -- what Library and Home + * put behind their content so the canvas is not flat (design-plan.md §5/§6). + * Uses CoverArt's own generated fallback plate when there is no screenshot + * yet, so it reads as MeshMC's own content, never stock art. + * + * The caller positions and sizes it and decides when it is visible; this + * only owns how strong the wash is, and -- when it does not reach the page's + * bottom -- how it fades out instead of ending on a hard edge. + */ +Item { + id: root + + property string cover: "" + property color tint: Theme.palette.textTertiary + // Set when the backdrop is a band (Home's top region) rather than the + // whole page, so its lower edge dissolves into the canvas. + property bool fadeBottom: false + // Home's own wide pixel-art hero panorama in place of CoverArt's small + // per-instance fallback plate whenever there is no real screenshot to + // show (design-plan.md G3d) -- a 480x90 scene fits this band's own wide, + // short shape far better than a 96x54 landscape stretched to match it. + // Library keeps the default (false): its backdrop is always one real + // instance's own art or that instance's own small fallback plate. + property bool wideHero: false + readonly property bool showHero: root.wideHero && root.cover.length === 0 + + // A real screenshot stays inside the 6-10% design-plan.md §6 sets for a + // background wash, biased to the top of that budget: at the low end it + // was confirmed to sit at the edge of visible in an actual screenshot, + // barely distinguishable from noise. CoverArt's fallback plate is a + // smoother, lower-contrast gradient, so it needs a little more still to + // read as tonal variation at all. Cover art is mostly dark, so on the + // light canvas the same amount reads as a grey cast rather than a wash + // -- scaled down there. + readonly property real photoOpacity: Theme.dark ? 0.15 : 0.095 + readonly property real plateOpacity: Theme.dark ? 0.18 : 0.12 + + clip: true + + Item { + anchors.fill: parent + visible: !root.showHero + opacity: root.cover.length > 0 ? root.photoOpacity : root.plateOpacity + + CoverArt { + anchors.fill: parent + source: root.cover + tint: root.tint + iconKey: "" + radius: 0 + scrim: "none" + // A real screenshot's hard edges (rooflines, tree cover) turn + // blotchy at wash opacity when decoded sharp; the fallback plate + // is already a smooth gradient and does not need this. + photoSoftness: root.cover.length > 0 ? 0.12 : 1.0 + } + } + + // Home with no recent screenshot at all: its own wide panorama instead + // of the per-instance fallback plate above (see `wideHero`). + Image { + anchors.fill: parent + visible: root.showHero + opacity: root.plateOpacity + source: root.showHero ? PixelArt.heroUrl() : "" + fillMode: Image.PreserveAspectCrop + smooth: false + asynchronous: true + cache: true + } + + // Outside the faded Item on purpose: Qt Quick applies opacity per child, + // so a canvas-coloured overlay inside it would only cover its own few + // percent of the art. Drawn at full strength here it replaces the wash + // with the canvas itself by the bottom edge. + Rectangle { + visible: root.fadeBottom + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: parent.height * 0.45 + gradient: Gradient { + GradientStop { position: 0.0; color: Qt.rgba(Theme.palette.canvas.r, Theme.palette.canvas.g, Theme.palette.canvas.b, 0) } + GradientStop { position: 1.0; color: Theme.palette.canvas } + } + } +} diff --git a/launcher/qml/Components/PalettePicker.qml b/launcher/qml/Components/PalettePicker.qml new file mode 100644 index 00000000..2a833ecb --- /dev/null +++ b/launcher/qml/Components/PalettePicker.qml @@ -0,0 +1,185 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import MeshMC.Theme + +/* + * The colour schemes side by side, each drawn as a miniature launcher in its + * own colours (sidebar, two cards, a Play button) for the current light or + * dark mode, so the choice is made by looking rather than by name. + * + * Flow rather than Row: a fourth card (Grass) made the plain Row wider than + * Settings' own panel at the window's default width, so it wraps onto a + * second line instead of overflowing once its host layout constrains this + * item's own width (SettingRow's `wide` mode gives it Layout.fillWidth). + */ +Flow { + id: root + + // The scheme shown as chosen; "amethyst", "ember", "diamond" or "grass". + property string current: Theme.scheme + signal picked(string scheme) + + readonly property var schemes: [ + { id: "grass", label: qsTr("Grass"), note: qsTr("Graphite and grass green") }, + { id: "amethyst", label: qsTr("Obsidian"), note: qsTr("Graphite and amethyst") }, + { id: "ember", label: qsTr("Ember"), note: qsTr("Charcoal and lava") }, + { id: "diamond", label: qsTr("Diamond"), note: qsTr("Navy and diamond blue") } + ] + + // Bound to the host's width (SettingRow's `wide` slot resizes to the + // panel) rather than left at Flow's own unconstrained implicit width -- + // otherwise Flow has nothing to wrap against and behaves exactly like + // the Row it replaced. + width: parent ? parent.width : implicitWidth + spacing: Theme.space.md + + Repeater { + model: root.schemes + + delegate: AbstractButton { + id: card + required property var modelData + // Re-read whenever the mode flips, so the previews follow it. + readonly property var colors: Theme.dark, Theme.previewPalette(modelData.id) + readonly property bool chosen: root.current === modelData.id + + width: 188 + height: 150 + hoverEnabled: true + Accessible.name: modelData.label + Accessible.role: Accessible.RadioButton + Accessible.checked: chosen + onClicked: root.picked(modelData.id) + + background: Rectangle { + radius: Theme.radius.lg + color: Theme.palette.surfaceRaised + border.width: card.chosen ? 2 : 1 + border.color: card.chosen ? Theme.palette.accent + : card.hovered ? Theme.palette.borderStrong : Theme.palette.border + Behavior on border.color { ColorAnimation { duration: Theme.motion.fast } } + } + + contentItem: Column { + spacing: Theme.space.sm + + // The miniature launcher. + Rectangle { + width: parent.width + height: 86 + radius: Theme.radius.md + color: card.colors.canvas + border.width: 1 + border.color: card.colors.border + clip: true + + Rectangle { + id: miniSidebar + x: 0 + width: 34 + height: parent.height + color: card.colors.surface + Rectangle { + x: 6; y: 10 + width: 22; height: 6 + radius: Theme.radius.xs + color: card.colors.accentSubtle + Rectangle { width: 2; height: parent.height; color: card.colors.accent } + } + Repeater { + model: 2 + delegate: Rectangle { + required property int index + x: 6; y: 22 + index * 10 + width: 18; height: 4 + radius: Theme.radius.xs + color: card.colors.textTertiary + opacity: 0.6 + } + } + } + Row { + x: miniSidebar.width + 8 + y: 10 + spacing: 6 + Repeater { + model: 3 + delegate: Rectangle { + width: 36; height: 30 + radius: Theme.radius.sm + color: card.colors.surfaceRaised + border.width: 1 + border.color: card.colors.border + } + } + } + Rectangle { + anchors.horizontalCenter: parent.horizontalCenter + anchors.horizontalCenterOffset: miniSidebar.width / 2 + anchors.bottom: parent.bottom + anchors.bottomMargin: 8 + width: 64; height: 18 + radius: Theme.radius.sm + color: card.colors.accent + Text { + anchors.centerIn: parent + text: qsTr("PLAY") + color: card.colors.textOnAccent + font.family: Theme.font.family + font.pixelSize: 9 + font.weight: Font.Bold + font.letterSpacing: 1 + } + } + } + + Row { + width: parent.width + spacing: Theme.space.xs + Column { + width: parent.width - check.width - parent.spacing + Text { + width: parent.width + text: card.modelData.label + elide: Text.ElideRight + color: Theme.palette.textPrimary + font.family: Theme.font.family + font.pixelSize: Theme.type.bodyStrong.pixelSize + font.weight: Theme.type.bodyStrong.weight + } + Text { + width: parent.width + text: card.modelData.note + elide: Text.ElideRight + color: Theme.palette.textTertiary + font.family: Theme.font.family + font.pixelSize: Theme.type.caption.pixelSize + } + } + Rectangle { + id: check + anchors.verticalCenter: parent.verticalCenter + width: Theme.icon.md + height: width + radius: width / 2 + color: card.chosen ? Theme.palette.accent : "transparent" + border.width: card.chosen ? 0 : 1 + border.color: Theme.palette.borderStrong + MeshIcon { + anchors.centerIn: parent + visible: card.chosen + iconName: "check" + size: Theme.icon.sm - 2 + color: Theme.palette.textOnAccent + } + } + } + } + padding: Theme.space.sm + } + } +} diff --git a/launcher/qml/Components/PixelArt.qml b/launcher/qml/Components/PixelArt.qml new file mode 100644 index 00000000..eae569ff --- /dev/null +++ b/launcher/qml/Components/PixelArt.qml @@ -0,0 +1,99 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +pragma Singleton + +import QtQuick + +/* + * Lookup for the original pixel-art PNGs under art/ (see art/generate.py -- + * every file here is procedurally generated by that checked-in script, never + * at runtime), the same shape as Icons.qml's url() for SVGs. + * + * landscapeUrl() is the one callers reach for most: CoverArt's fallback art, + * PlayDock's photo-less bar and Home's jump cards all pick one of 24 + * generated scenes by hashing the instance/world id, so a given item always + * draws the same scene rather than a new one on every repaint + * (design-plan.md §6 "fallback covers": bounded and designed, not + * random-looking). Each scene exists at three shapes -- "card", "band" and + * "strip" -- because a 16:9 scene cropped into a 12:1 dock bar only ever + * shows a sliver of plain sky; shapeForAspect() picks the one that fits its + * host. landscapeMirror() then flips roughly half of the hashed ids, which + * doubles the number of distinct looks for free. + * + * blockUrlFor() serves the 16x16 block textures the same way, for the small + * square spots (a world's icon) where a whole scene would only be a smear. + */ +QtObject { + readonly property int sceneCount: 24 + readonly property var blockNames: [ + "grass_side", "dirt", "stone", "cobblestone", "oak_planks", + "deepslate", "sand", "gravel", "grass_top" + ] + readonly property string _root: "qrc:/qt/qml/MeshMC/Components/art/" + + // A plain, fast string hash (djb2-ish) -- only needs to be stable across + // calls in this process, never to match anything outside QML. + function _hash(text) { + var h = 5381 + for (var i = 0; i < text.length; ++i) + h = ((h * 33) ^ text.charCodeAt(i)) >>> 0 + return h + } + + function _key(seed) { + return seed && seed.length > 0 ? seed : "mesh" + } + + // "card" (about 16:9), "band" (about 5:1) or "strip" (about 12:1) -- + // whichever of the three generated shapes a host of this width/height + // ratio would crop the least. + function shapeForAspect(aspect) { + return aspect > 8 ? "strip" : aspect > 3 ? "band" : "card" + } + + // @p seed is normally an instance or world id; "" still returns a valid, + // stable pick rather than an empty source. @p shape is one of + // shapeForAspect()'s three names (default "card"). + function landscapeUrl(seed, shape) { + var idx = _hash(_key(seed)) % sceneCount + return _root + "landscapes/" + (shape || "card") + "/" + (idx < 10 ? "0" : "") + idx + ".png" + } + + // Whether this seed's landscape is drawn flipped left-to-right. Uses a + // different bit of the hash than landscapeUrl()'s index, so the two + // choices do not correlate. + function landscapeMirror(seed) { + return ((_hash(_key(seed)) >>> 9) & 1) === 1 + } + + // @p name is one of art/blocks/*.png without its extension, e.g. "dirt". + function blockUrl(name) { + return _root + "blocks/" + name + ".png" + } + + // A block texture picked by hashing @p seed -- a stable, generic icon for + // a world that has none of its own. + function blockUrlFor(seed) { + return blockUrl(blockNames[_hash(_key(seed)) % blockNames.length]) + } + + // AmbientPattern's own tileable wash: a dirt/deepslate checkerboard + // pre-converted to a luminance alpha mask (see generate.py's + // ambient_tile()), so IconImage's alpha-channel recolouring -- the same + // mechanism MeshIcon.qml already uses for every SVG icon -- can tint it + // to any palette colour at draw time. + function ambientMaskUrl() { + return _root + "ambient/blocks_mask.png" + } + + function heroUrl() { + return _root + "hero/home_hero.png" + } + + // @p kind is one of art/empty/*.png without its extension, e.g. "no_worlds". + function emptyUrl(kind) { + return _root + "empty/" + kind + ".png" + } +} diff --git a/launcher/qml/Components/PlayButton.qml b/launcher/qml/Components/PlayButton.qml new file mode 100644 index 00000000..b2d020fa --- /dev/null +++ b/launcher/qml/Components/PlayButton.qml @@ -0,0 +1,201 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import MeshMC.Theme + +/* + * The launcher's one most important button. Round and icon-only on a card, + * wide with a label in the hero; turns into Stop while the game runs, in + * the danger colour, so the two can never be confused at a glance. + * + * `hero` marks the bottom play bar's own instance of this button: the only + * thing it still changes is a short burst of pixel squares on click. Hover + * and press everywhere -- hero or not -- only ever change this button's own + * fill colour, one property, over Theme.motion.fast; an earlier pass added + * a hover scale, a layered glow, bevel hairlines and an idle glint sweep, + * which read as exactly the kind of "flat-and-purple, gradient-happy, + * hovers-do-something-weird" look this app is deliberately not going for. + * + * The wide (non-round) shape does keep one static block bevel -- a fixed, + * un-animated darker strip along the bottom edge, plus a blockier + * `Theme.radius.sm` corner -- so it reads as a Minecraft-style block button + * rather than a flat web pill. This is not the earlier hover-driven bevel + * hairlines above: it never changes on hover/press beyond following the + * same fill colour they already do (see `bevel`), and the round icon-only + * variant keeps its plain circular shape. + */ +AbstractButton { + id: control + + property bool running: false + // A launch is being prepared: the button stays in place, says so, and + // does not fade like a disabled one. + property bool busy: false + property bool round: true + property int size: Theme.control.height + 4 + property bool hero: false + + // Bumped on every click that should burst -- each pixel-square delegate + // below watches this rather than a one-shot Animation's own started() + // signal, since an Animation with no child animations to run is not a + // reliable way to broadcast "now". + property int burstSeed: 0 + + text: busy ? qsTr("Starting…") : running ? qsTr("Stop") : qsTr("Play") + hoverEnabled: true + implicitHeight: size + implicitWidth: round ? size : Math.max(size * 3, label.implicitWidth + Theme.icon.md + Theme.space.xl * 2 + Theme.space.sm) + opacity: enabled || busy ? 1.0 : Theme.opacity.disabled + + Accessible.name: text + + onClicked: if (control.hero && !control.running && !control.busy) control.burstSeed++ + + // The one property hover/press are allowed to change: a lighter fill on + // hover, a darker one when pressed, animated over Theme.motion.fast -- + // see the file comment. + readonly property color fill: running + ? (down ? Qt.darker(Theme.palette.danger, 1.15) : hovered ? Qt.lighter(Theme.palette.danger, 1.08) : Theme.palette.danger) + : (down ? Theme.palette.accentPressed : hovered ? Theme.palette.accentHover : Theme.palette.accent) + + // A block/button bevel: a static 3px darker strip along the bottom + // edge, the way a Minecraft-style button reads as a physical block + // rather than a flat web pill -- never animated (see the file comment's + // one-property-only rule), and skipped on the round icon-only variant, + // which was never meant to look blocky. `bg` itself is this darker + // colour at full height; `fillTop` -- the actual button face -- sits on + // top of it, 3px shorter, exposing the strip along the bottom. Darker + // (1.5x, was 1.35x) and one row taller than an earlier pass, which read + // as an almost invisible shadow rather than a crisp step next to the + // static top-gloss gradient below. + readonly property color bevel: Qt.darker(control.fill, 1.5) + + background: Rectangle { + id: bg + radius: control.round ? height / 2 : Theme.radius.sm + color: control.bevel + Behavior on color { ColorAnimation { duration: Theme.motion.fast; easing.type: Theme.motion.easing } } + + Rectangle { + id: fillTop + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + height: control.round ? parent.height : parent.height - 3 + // Square, not `parent.radius`: a rounded bottom edge here would + // recede at the corners by the same radius as `bg`, leaving the + // exposed bevel strip below as a thin curved sliver instead of + // a flat step across the width. The overlay just below re-rounds + // only the top two corners, in the same fill colour, so the + // square base is invisible except at the true bottom edge. + radius: control.round ? height / 2 : 0 + color: control.fill + Behavior on color { ColorAnimation { duration: Theme.motion.fast; easing.type: Theme.motion.easing } } + + Rectangle { + visible: !control.round + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + height: Theme.radius.sm + radius: Theme.radius.sm + color: control.fill + } + } + + // Static top gloss -- not hover-driven, just a fixed hint of light + // from above. + Rectangle { + anchors.fill: parent + radius: parent.radius + visible: !control.down + gradient: Gradient { + GradientStop { position: 0.0; color: Qt.rgba(1, 1, 1, 0.25) } + GradientStop { position: 0.6; color: Qt.rgba(1, 1, 1, 0.0) } + } + } + + Rectangle { + anchors.fill: parent + anchors.margins: -3 + radius: parent.radius + 3 + color: "transparent" + border.width: 2 + border.color: Theme.palette.focusRing + visible: control.visualFocus + } + } + + contentItem: Item { + Row { + anchors.centerIn: parent + spacing: Theme.space.sm + + MeshIcon { + anchors.verticalCenter: parent.verticalCenter + visible: !control.busy + iconName: control.running ? "stop" : "play" + size: control.round ? Math.round(control.size * 0.42) : Theme.icon.md - 2 + color: Theme.palette.textOnAccent + } + + Text { + id: label + anchors.verticalCenter: parent.verticalCenter + visible: !control.round + text: control.text + color: Theme.palette.textOnAccent + font.family: Theme.font.family + font.pixelSize: Theme.type.title.pixelSize + font.weight: Font.Bold + } + } + } + + // A short, one-shot burst of small pixel squares in accent colours on + // click -- purely decorative, so it runs once and stops rather than + // looping; nothing here costs idle CPU once it finishes. + Item { + anchors.fill: parent + visible: control.hero + clip: false + + Repeater { + model: control.hero ? 8 : 0 + delegate: Rectangle { + id: chip + required property int index + readonly property real angle: (index / 8) * Math.PI * 2 + width: 4 + height: 4 + color: index % 2 === 0 ? Theme.palette.accentHover : Theme.palette.textOnAccent + x: control.width / 2 - width / 2 + y: control.height / 2 - height / 2 + opacity: 0 + + ParallelAnimation { + id: fly + NumberAnimation { target: chip; property: "x"; to: control.width / 2 - chip.width / 2 + Math.cos(chip.angle) * control.height * 0.9; duration: 420; easing.type: Easing.OutCubic } + NumberAnimation { target: chip; property: "y"; to: control.height / 2 - chip.height / 2 + Math.sin(chip.angle) * control.height * 0.9; duration: 420; easing.type: Easing.OutCubic } + SequentialAnimation { + NumberAnimation { target: chip; property: "opacity"; to: 1; duration: 60 } + PauseAnimation { duration: 180 } + NumberAnimation { target: chip; property: "opacity"; to: 0; duration: 180 } + } + } + + Connections { + target: control + function onBurstSeedChanged() { + chip.x = control.width / 2 - chip.width / 2 + chip.y = control.height / 2 - chip.height / 2 + fly.restart() + } + } + } + } + } +} diff --git a/launcher/qml/Components/PlayDock.qml b/launcher/qml/Components/PlayDock.qml new file mode 100644 index 00000000..afbe4ec4 --- /dev/null +++ b/launcher/qml/Components/PlayDock.qml @@ -0,0 +1,545 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import MeshMC.Theme + +/* + * The persistent bottom play bar: the selected instance on the left with a + * picker to change it, the big Play button anchored at the right so it sits + * in the window's bottom-right corner. Replaces the library's old + * "Continue playing" hero -- one always-there place to press Play instead + * of one that only showed up on the Library page. + * + * This is a genuine row in Main.qml's own layout, not a floating overlay: + * StackLayout's page area shrinks by exactly this bar's height whenever it + * is visible, so it can never cover a page's content, on any page -- including + * ones this change does not own the QML of (Discover). + * + * `rowModel` is the shell's heroModel, freed up by removing the library's + * own hero card; Main.qml points its instanceId at whichever instance this + * bar should show (see its own dockInstanceId). + */ +Item { + id: root + + // Single-row model (QmlShell.heroModel) already bound to the instance + // this bar shows -- see Main.qml's dockInstanceId. + property var rowModel: null + // The full library list, for the picker's search field. + property var instanceModel: null + // Most-recently-played first, for the picker's default listing. + property var recentModel: null + // Main.qml's selectedId, so the picker can highlight the current row. + property string selectedId: "" + + signal selectRequested(string id) + signal launchRequested(string id) + signal stopRequested(string id) + signal cancelRequested(string id) + + // Every decorative loop stops when the window is not the active one and + // when the user has asked for less motion. + readonly property bool motionEnabled: Qt.application.state === Qt.ApplicationActive + && !SettingsStore.bool("UiReduceMotion") + + implicitHeight: 84 + + activeFocusOnTab: true + Keys.onReturnPressed: root.tryLaunch() + Keys.onSpacePressed: root.tryLaunch() + function tryLaunch() { + if (!root.hasInstance || root.launching) + return + if (root.dockRunning) + root.stopRequested(root.dockInstanceId) + else if (root.dockCanLaunch) + root.launchRequested(root.dockInstanceId) + } + + Accessible.role: Accessible.Pane + Accessible.name: qsTr("Play bar") + + // For a dev-route snapshot ("picker") that needs the instance picker + // open without a real click. + function openPicker() { picker.open() } + + // -- The current row, read out of rowModel's one row -------------------- + // Same idiom as InstancePage's header ContinueCard/InstanceOverviewTab + // sync: a role model with 0-1 rows has no properties of its own to bind + // to directly, so a throwaway delegate reads its roles into plain + // properties everything else here can use. + property string dockInstanceId: "" + property string dockName: "" + property string dockIconKey: "" + property bool dockRunning: false + property bool dockCanLaunch: false + property var dockTotalTimePlayed: 0 + property string dockGameVersion: "" + property string dockLoader: "" + property color dockIconTint: Theme.palette.textTertiary + property string dockCoverImage: "" + property string dockLaunchStatus: "" + property real dockLaunchProgress: -1 + + readonly property bool hasInstance: root.dockInstanceId.length > 0 + readonly property bool launching: root.dockLaunchStatus.length > 0 + + Repeater { + model: root.rowModel + delegate: Item { + id: probe + required property string instanceId + required property string name + required property string iconKey + required property bool isRunning + required property bool canLaunch + required property var totalTimePlayed + required property string gameVersion + required property string loader + required property color iconTint + required property string coverImage + required property string launchStatus + required property real launchProgress + visible: false + width: 0 + height: 0 + + function sync() { + root.dockInstanceId = probe.instanceId + root.dockName = probe.name + root.dockIconKey = probe.iconKey + root.dockRunning = probe.isRunning + root.dockCanLaunch = probe.canLaunch + root.dockTotalTimePlayed = probe.totalTimePlayed + root.dockGameVersion = probe.gameVersion + root.dockLoader = probe.loader + root.dockIconTint = probe.iconTint + root.dockCoverImage = probe.coverImage + root.dockLaunchStatus = probe.launchStatus + root.dockLaunchProgress = probe.launchProgress + } + Component.onCompleted: sync() + onNameChanged: sync() + onIconKeyChanged: sync() + onIsRunningChanged: sync() + onCanLaunchChanged: sync() + onTotalTimePlayedChanged: sync() + onGameVersionChanged: sync() + onLoaderChanged: sync() + onIconTintChanged: sync() + onCoverImageChanged: sync() + onLaunchStatusChanged: sync() + onLaunchProgressChanged: sync() + } + } + // No row at all (library empty, or the model has not been pointed at an + // id yet): fall back to blank rather than stale leftovers. + onRowModelChanged: if (!rowModel) { dockInstanceId = ""; dockName = "" } + + // -- Backdrop: the selected instance's own screenshot, darkened -------- + // The art itself spans the whole bar (CoverArt's own pixel-art "strip" + // landscape covers an instance with no screenshot yet); scrim: "none" + // here because CoverArt's own horizontal scrim jumps to 62% opacity by + // the 45% mark, which read as one flat dark block on the left and a + // separate bright picture on the right rather than one continuous + // surface. The gradient below fades across the full width instead, so + // the art is never fully hidden, only ever darkened. + CoverArt { + id: backdrop + anchors.fill: parent + // Square corners: this bar sits flush against the window's own + // bottom and side edges, where a rounded photo would look cut off + // rather than intentional. radius: 0 also skips CoverArt's corner + // mask entirely, so there is no matte colour to get right here. + radius: 0 + source: root.dockCoverImage + tint: root.dockIconTint + iconKey: "" + seed: root.dockInstanceId + scrim: "none" + } + Rectangle { + id: scrimRect + anchors.fill: parent + // GradientStop's own `parent` is the Gradient, not this Rectangle -- + // referencing this id directly instead of `parent` is what a + // GradientStop child actually needs to reach a property declared + // out here. + readonly property color scrimBase: Theme.media.scrim + // This bar's text is always light regardless of theme, and the + // generated landscape behind it can be a pale day sky in either + // theme (CoverArt dims it a little in dark mode only) -- so the + // same scrim runs over a photo or not. + gradient: Gradient { + orientation: Gradient.Horizontal + GradientStop { position: 0.0; color: Qt.rgba(scrimRect.scrimBase.r, scrimRect.scrimBase.g, scrimRect.scrimBase.b, 0.80) } + GradientStop { position: 0.35; color: Qt.rgba(scrimRect.scrimBase.r, scrimRect.scrimBase.g, scrimRect.scrimBase.b, 0.55) } + GradientStop { position: 0.7; color: Qt.rgba(scrimRect.scrimBase.r, scrimRect.scrimBase.g, scrimRect.scrimBase.b, 0.28) } + GradientStop { position: 1.0; color: Qt.rgba(scrimRect.scrimBase.r, scrimRect.scrimBase.g, scrimRect.scrimBase.b, 0.14) } + } + } + // A thin highlight along the top edge instead of a plain divider -- + // the bar reads as one raised surface rather than a hairline-separated + // strip. + Rectangle { + anchors.top: parent.top + width: parent.width + height: 1 + color: Qt.rgba(1, 1, 1, 0.10) + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: Theme.space.xl + Theme.space.xs + anchors.rightMargin: Theme.space.lg + spacing: Theme.space.md + + AbstractButton { + id: pickerButton + Layout.alignment: Qt.AlignVCenter + implicitWidth: pickerRow.implicitWidth + Theme.space.md * 2 + implicitHeight: Theme.control.heightLg + Theme.space.sm + hoverEnabled: true + Accessible.name: root.hasInstance ? qsTr("Change instance: %1").arg(root.dockName) : qsTr("Choose an instance") + + onClicked: picker.visible ? picker.close() : picker.open() + + background: Rectangle { + radius: Theme.radius.lg + color: pickerButton.hovered ? Qt.rgba(1, 1, 1, 0.08) : "transparent" + Behavior on color { ColorAnimation { duration: Theme.motion.fast; easing.type: Theme.motion.easing } } + } + + contentItem: RowLayout { + id: pickerRow + spacing: Theme.space.sm + + Rectangle { + Layout.preferredWidth: 44 + Layout.preferredHeight: 44 + radius: Theme.radius.md + color: Format.shade(root.dockIconTint, Theme.dark ? 0.30 : 0.86, 0.55) + border.width: 1 + border.color: Qt.rgba(1, 1, 1, 0.10) + + Image { + anchors.centerIn: parent + width: 30 + height: 30 + visible: root.hasInstance + source: root.dockIconKey.length > 0 ? "image://instanceicon/" + root.dockIconKey : "" + sourceSize: Qt.size(width, height) + fillMode: Image.PreserveAspectFit + } + MeshIcon { + anchors.centerIn: parent + visible: !root.hasInstance + iconName: "library" + size: Theme.icon.md + color: Theme.media.textSecondary + } + } + + // A ColumnLayout, not a plain Column with a fixed width: a + // short instance name should sit close to its icon with the + // chevron right after it, not leave a dead gap up to a + // hard-coded column width -- Layout.maximumWidth below caps + // growth (eliding) without forcing short names to stretch. + ColumnLayout { + Layout.alignment: Qt.AlignVCenter + spacing: 1 + + Text { + Layout.maximumWidth: 190 + text: root.hasInstance ? root.dockName : qsTr("Choose an instance") + elide: Text.ElideRight + color: Theme.media.text + font.family: Theme.font.family + font.pixelSize: Theme.type.bodyStrong.pixelSize + font.weight: Theme.type.bodyStrong.weight + } + Text { + id: subtitle + Layout.maximumWidth: 190 + visible: root.hasInstance && subtitle.text.length > 0 + text: [Format.versionLine(root.dockLoader, root.dockGameVersion), + Format.playTime(root.dockTotalTimePlayed)].filter(s => s.length > 0).join(" · ") + elide: Text.ElideRight + color: Theme.media.textSecondary + font.family: Theme.font.family + font.pixelSize: Theme.type.caption.pixelSize + } + } + + MeshIcon { + Layout.alignment: Qt.AlignVCenter + iconName: "chevron-down" + size: Theme.icon.sm + color: Theme.media.textSecondary + rotation: picker.visible ? 180 : 0 + Behavior on rotation { NumberAnimation { duration: Theme.motion.fast; easing.type: Theme.motion.easing } } + } + } + } + + Item { Layout.fillWidth: true } + + // Ready / launching / running -- three mutually exclusive faces, + // cross-fading into each other rather than swapping abruptly, so a + // click's burst on the Play button visibly "morphs" into the + // progress display once the real launch status arrives. + Item { + Layout.alignment: Qt.AlignVCenter + Layout.preferredWidth: Math.max(readyFace.implicitWidth, launchingFace.implicitWidth, runningFace.implicitWidth) + Layout.preferredHeight: Theme.control.heightLg + 6 + + PlayButton { + id: readyFace + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + hero: true + round: false + size: Theme.control.heightLg + 6 + enabled: root.hasInstance && root.dockCanLaunch + opacity: root.hasInstance && !root.launching && !root.dockRunning ? 1 : 0 + visible: opacity > 0 + Behavior on opacity { NumberAnimation { duration: Theme.motion.normal; easing.type: Theme.motion.easing } } + onClicked: root.launchRequested(root.dockInstanceId) + } + + Row { + id: launchingFace + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.space.md + opacity: root.launching ? 1 : 0 + visible: opacity > 0 + Behavior on opacity { NumberAnimation { duration: Theme.motion.normal; easing.type: Theme.motion.easing } } + + Column { + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.space.xxs + width: 220 + + Text { + width: parent.width + text: root.dockLaunchProgress >= 0 + ? qsTr("%1 · %2%").arg(root.dockLaunchStatus).arg(Math.round(root.dockLaunchProgress * 100)) + : root.dockLaunchStatus + elide: Text.ElideRight + color: Theme.media.text + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + font.weight: Font.Medium + } + LaunchProgressBar { + width: parent.width + implicitHeight: 8 + onMedia: true + segmented: true + progress: root.dockLaunchProgress + } + } + + IconButton { + anchors.verticalCenter: parent.verticalCenter + size: Theme.control.heightLg + flat: false + iconName: "x" + tip: qsTr("Cancel") + onClicked: root.cancelRequested(root.dockInstanceId) + } + } + + Row { + id: runningFace + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.space.md + opacity: !root.launching && root.dockRunning ? 1 : 0 + visible: opacity > 0 + Behavior on opacity { NumberAnimation { duration: Theme.motion.normal; easing.type: Theme.motion.easing } } + + Row { + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.space.sm + + Rectangle { + anchors.verticalCenter: parent.verticalCenter + width: 9 + height: 9 + radius: 4.5 + color: Theme.palette.success + + SequentialAnimation on opacity { + running: root.dockRunning && root.motionEnabled + loops: Animation.Infinite + NumberAnimation { from: 1.0; to: 0.4; duration: 900; easing.type: Easing.InOutSine } + NumberAnimation { from: 0.4; to: 1.0; duration: 900; easing.type: Easing.InOutSine } + } + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: qsTr("Playing") + color: Theme.media.text + font.family: Theme.font.family + font.pixelSize: Theme.type.bodyStrong.pixelSize + font.weight: Font.Bold + } + } + + PlayButton { + anchors.verticalCenter: parent.verticalCenter + hero: true + round: false + size: Theme.control.heightLg + 6 + running: true + onClicked: root.stopRequested(root.dockInstanceId) + } + } + } + } + + // -- The instance picker ------------------------------------------------- + Popup { + id: picker + x: Theme.space.xl + Theme.space.xs + y: -height - Theme.space.sm + width: 360 + // The list's own height already caps at listMaxHeight below; deriving + // this from that exact same expression (rather than a separate + // guessed constant) is what keeps the two from ever disagreeing -- + // a popup capped shorter than the list it contains was exactly why + // the last row used to be sliced off mid-row. Tall enough for a + // realistic recent-instances list (7-8 rows at this row height) + // to show in full without scrolling at all; a library with more + // than that scrolls, and lands on a whole row at the bottom via + // the list's own matching topMargin/bottomMargin below. + readonly property int listMaxHeight: 360 + // Search field + the ColumnLayout's own spacing + this popup's own + // top/bottom padding -- everything in this popup that is not the + // list itself. + readonly property int chromeHeight: Theme.control.height + Theme.space.sm + padding * 2 + height: chromeHeight + Math.min(listMaxHeight, Math.max(1, list.contentHeight)) + padding: Theme.space.sm + // Belt and braces alongside the height match above: nothing paints + // past this popup's own rounded background. + clip: true + + property string savedFilter: "" + onOpened: { + picker.savedFilter = root.instanceModel ? root.instanceModel.filterText : "" + searchField.text = "" + searchField.forceActiveFocus() + } + onClosed: if (root.instanceModel) root.instanceModel.filterText = picker.savedFilter + + contentItem: ColumnLayout { + spacing: Theme.space.sm + + SearchBox { + id: searchField + Layout.fillWidth: true + placeholderText: qsTr("Search instances") + onTextChanged: if (root.instanceModel) root.instanceModel.filterText = text + } + + ListView { + id: list + Layout.fillWidth: true + Layout.preferredHeight: Math.min(picker.listMaxHeight, Math.max(1, contentHeight)) + // Equal top and bottom breathing room, so a fully scrolled + // list ends with clear space under the last row instead of + // stopping exactly on its bottom edge. + topMargin: Theme.space.xs + bottomMargin: Theme.space.xs + clip: true + boundsBehavior: Flickable.StopAtBounds + // The default listing is most-recent-first; typing a search + // switches to the full library so an instance that has + // never been played can still be found. + model: searchField.text.length > 0 ? root.instanceModel : root.recentModel + ScrollBar.vertical: ScrollBar {} + + delegate: ItemDelegate { + id: row + required property string instanceId + required property string name + required property string iconKey + required property bool isRunning + required property string gameVersion + required property string loader + width: list.width + hoverEnabled: true + highlighted: row.instanceId === root.selectedId + + onClicked: { + root.selectRequested(row.instanceId) + picker.close() + } + + contentItem: RowLayout { + spacing: Theme.space.sm + + Image { + Layout.preferredWidth: 28 + Layout.preferredHeight: 28 + source: row.iconKey.length > 0 ? "image://instanceicon/" + row.iconKey : "" + // A fixed size, not Qt.size(width, height): this + // Image's own width/height come from Layout. + // preferred* rather than a literal, and tying + // sourceSize back to them is exactly the binding + // loop QQuickImage warns about. + sourceSize: Qt.size(56, 56) + fillMode: Image.PreserveAspectFit + } + Column { + Layout.fillWidth: true + Layout.minimumWidth: 0 + spacing: 0 + + Text { + width: parent.width + text: row.name + elide: Text.ElideRight + color: Theme.palette.textPrimary + font.family: Theme.font.family + font.pixelSize: Theme.type.body.pixelSize + font.weight: Font.Medium + } + Text { + width: parent.width + text: Format.versionLine(row.loader, row.gameVersion) + elide: Text.ElideRight + color: Theme.palette.textTertiary + font.family: Theme.font.family + font.pixelSize: Theme.type.caption.pixelSize + } + } + Rectangle { + visible: row.isRunning + Layout.preferredWidth: 7 + Layout.preferredHeight: 7 + radius: 3.5 + color: Theme.palette.success + } + } + } + + EmptyState { + anchors.centerIn: parent + visible: list.count === 0 + title: qsTr("No instances found") + body: "" + actionText: "" + + MeshIcon { iconName: "search"; size: 32; color: Theme.palette.textTertiary } + } + } + } + } +} diff --git a/launcher/qml/Components/PluginNode.qml b/launcher/qml/Components/PluginNode.qml new file mode 100644 index 00000000..afe9ed36 --- /dev/null +++ b/launcher/qml/Components/PluginNode.qml @@ -0,0 +1,347 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import MeshMC.Theme + +/* + * One node of a plugin's "mmco-ui/1" document, drawn with the launcher's + * own components -- a plugin names what it needs (a toggle, a list), never + * how it looks. Containers draw their children through this same file. + * + * Every user action goes back to the plugin as (surface, node, event, + * value) through `sink.sendEvent`; the plugin answers by patching its + * document, which arrives here as a new `node`. + */ +Item { + id: root + + property var node: ({}) + property string surfaceId + // Has sendEvent(surfaceId, nodeId, event, value). + property var sink: null + + readonly property string type: node && node.type ? node.type : "" + readonly property var props: node && node.props ? node.props : ({}) + readonly property var children_: node && node.children ? node.children : [] + + function send(event, value) { + if (root.sink) + root.sink.sendEvent(root.surfaceId, root.node.id || "", event, value) + } + + // Children of a row keep their natural width; everything else fills. + property bool stretch: true + + visible: props.visible !== false + implicitWidth: loader.implicitWidth + implicitHeight: visible ? loader.implicitHeight : 0 + width: stretch && parent ? parent.width : implicitWidth + + Loader { + id: loader + width: root.stretch ? parent.width : implicitWidth + sourceComponent: { + switch (root.type) { + case "column": return columnNode + case "row": return rowNode + case "section": return sectionNode + case "heading": return headingNode + case "text": return textNode + case "separator": return separatorNode + case "progress": return progressNode + case "button": return buttonNode + case "toggle": return toggleNode + case "text_field": return textFieldNode + case "number_field": return numberFieldNode + case "choice": return choiceNode + case "list": return listNode + case "link": return linkNode + default: return null + } + } + } + + Component { + id: columnNode + Column { + spacing: Theme.space.md + Repeater { + model: root.children_ + delegate: Loader { + required property var modelData + width: parent ? parent.width : 0 + source: Qt.resolvedUrl("PluginNode.qml") + onLoaded: { item.inCard = root.inCard; item.surfaceId = root.surfaceId; item.sink = root.sink; item.node = Qt.binding(() => modelData) } + } + } + } + } + + Component { + id: rowNode + Flow { + spacing: Theme.space.sm + Repeater { + model: root.children_ + delegate: Loader { + required property var modelData + source: Qt.resolvedUrl("PluginNode.qml") + onLoaded: { item.stretch = false; item.inCard = root.inCard; item.surfaceId = root.surfaceId; item.sink = root.sink; item.node = Qt.binding(() => modelData) } + } + } + } + } + + Component { + id: sectionNode + SettingsGroup { + title: root.props.title || "" + Repeater { + model: root.children_ + delegate: Loader { + required property var modelData + property bool showDivider: false + width: parent ? parent.width : 0 + source: Qt.resolvedUrl("PluginNode.qml") + onLoaded: { item.surfaceId = root.surfaceId; item.sink = root.sink; item.node = Qt.binding(() => modelData); item.inCard = true } + } + } + } + } + + // Leaves inside a section card get row padding, like setting rows. + property bool inCard: false + readonly property int pad: inCard ? Theme.space.lg : 0 + + Component { + id: headingNode + Text { + leftPadding: root.pad + text: root.props.text || "" + wrapMode: Text.Wrap + color: Theme.palette.textPrimary + font.family: Theme.font.family + font.pixelSize: Theme.type.title.pixelSize + font.weight: Font.Bold + } + } + + Component { + id: textNode + Text { + leftPadding: root.pad + rightPadding: root.pad + topPadding: root.inCard ? Theme.space.sm : 0 + bottomPadding: root.inCard ? Theme.space.sm : 0 + text: root.props.text || "" + textFormat: root.props.format === "markdown" ? Text.MarkdownText : Text.PlainText + wrapMode: Text.Wrap + color: Theme.palette.textSecondary + linkColor: Theme.palette.accent + font.family: Theme.font.family + font.pixelSize: Theme.type.body.pixelSize + onLinkActivated: (link) => root.send("click", link) + } + } + + Component { + id: separatorNode + Rectangle { + height: 1 + color: Theme.palette.divider + } + } + + Component { + id: progressNode + Item { + implicitHeight: 24 + LaunchProgressBar { + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: root.pad + anchors.rightMargin: root.pad + anchors.verticalCenter: parent.verticalCenter + progress: root.props.value === undefined || root.props.value < 0 ? -1 : root.props.value / 100 + } + } + } + + Component { + id: buttonNode + Item { + implicitWidth: button.implicitWidth + root.pad * 2 + implicitHeight: button.implicitHeight + (root.inCard ? Theme.space.sm * 2 : 0) + Button { + id: button + x: root.pad + anchors.verticalCenter: parent.verticalCenter + text: root.props.label || "" + enabled: root.props.enabled !== false + highlighted: root.props.style === "primary" + onClicked: root.send("click", null) + } + } + } + + Component { + id: toggleNode + SettingRow { + label: root.props.label || "" + description: root.props.description || "" + enabled: root.props.enabled !== false + Switch { + checked: root.props.value === true + Accessible.name: root.props.label || "" + onToggled: { + root.send("change", checked) + checked = Qt.binding(() => root.props.value === true) + } + } + } + } + + Component { + id: textFieldNode + SettingRow { + label: root.props.label || "" + wide: true + enabled: root.props.enabled !== false + TextField { + width: parent ? parent.width : 300 + placeholderText: root.props.placeholder || "" + selectByMouse: true + Component.onCompleted: text = root.props.value || "" + // Same moment the widget renderer reports it: when editing + // finishes, so a plugin never sees half-typed values. + onEditingFinished: root.send("change", text) + } + } + } + + Component { + id: numberFieldNode + SettingRow { + label: root.props.label || "" + enabled: root.props.enabled !== false + SpinBox { + width: 168 + from: root.props.min !== undefined ? root.props.min : 0 + to: root.props.max !== undefined ? root.props.max : 1000000 + stepSize: root.props.step !== undefined ? root.props.step : 1 + editable: true + value: root.props.value !== undefined ? root.props.value : 0 + onValueModified: root.send("change", value) + } + } + } + + Component { + id: choiceNode + SettingRow { + label: root.props.label || "" + enabled: root.props.enabled !== false + ComboBox { + id: combo + width: 220 + readonly property var options: (root.props.options || []).map(o => typeof o === "string" ? { id: o, label: o } : o) + model: options + textRole: "label" + valueRole: "id" + currentIndex: Math.max(0, options.findIndex(o => o.id === root.props.value)) + onActivated: root.send("change", currentValue) + } + } + } + + Component { + id: listNode + Column { + spacing: 2 + topPadding: root.inCard ? Theme.space.sm : 0 + bottomPadding: root.inCard ? Theme.space.sm : 0 + property string selectedId: "" + + // Column titles. + Row { + x: root.pad + Theme.space.sm + visible: (root.props.columns || []).length > 1 + spacing: Theme.space.md + Repeater { + model: root.props.columns || [] + delegate: Text { + required property string modelData + width: 120 + text: modelData + elide: Text.ElideRight + color: Theme.palette.textTertiary + font.family: Theme.font.family + font.pixelSize: Theme.type.caption.pixelSize + font.weight: Font.DemiBold + } + } + } + + Repeater { + model: root.props.rows || [] + delegate: AbstractButton { + id: listRow + required property var modelData + readonly property bool selected: parent.selectedId === modelData.id + x: root.pad + width: parent.width - root.pad * 2 + height: Theme.control.height + hoverEnabled: true + onClicked: { parent.selectedId = modelData.id; root.send("select", modelData.id) } + onDoubleClicked: root.send("activate", modelData.id) + background: Rectangle { + radius: Theme.radius.sm + 2 + color: listRow.selected ? Theme.palette.accentSubtle + : listRow.hovered ? Theme.palette.hoverOverlay : "transparent" + } + contentItem: Row { + leftPadding: Theme.space.sm + spacing: Theme.space.md + Repeater { + model: listRow.modelData.cells || [] + delegate: Text { + required property string modelData + required property int index + anchors.verticalCenter: parent ? parent.verticalCenter : undefined + width: index === 0 && (listRow.modelData.cells || []).length === 1 ? listRow.width - Theme.space.md : 120 + text: modelData + elide: Text.ElideRight + color: index === 0 ? Theme.palette.textPrimary : Theme.palette.textSecondary + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + } + } + } + } + } + } + } + + Component { + id: linkNode + Item { + implicitHeight: linkText.implicitHeight + (root.inCard ? Theme.space.sm * 2 : 0) + Text { + id: linkText + x: root.pad + anchors.verticalCenter: parent.verticalCenter + text: root.props.text || root.props.href || "" + color: Theme.palette.accent + font.family: Theme.font.family + font.pixelSize: Theme.type.body.pixelSize + font.underline: linkHover.hovered + HoverHandler { id: linkHover; cursorShape: Qt.PointingHandCursor } + TapHandler { onTapped: root.send("click", root.props.href || "") } + } + } + } +} diff --git a/launcher/qml/Components/PluginSurfaces.qml b/launcher/qml/Components/PluginSurfaces.qml new file mode 100644 index 00000000..5ae52163 --- /dev/null +++ b/launcher/qml/Components/PluginSurfaces.qml @@ -0,0 +1,69 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import MeshMC.Theme + +/* + * Every plugin surface for one place in the UI (the settings, one + * instance's page or settings), one after another, each under its title. + * `model` is the shell's PluginSurfaceModel for that anchor. + */ +Column { + id: root + + property var model: null + property bool showTitles: true + readonly property int count: repeater.count + // The title a single surface would give a tab; "" with none. + readonly property string firstTitle: repeater.count > 0 && repeater.itemAt(0) ? repeater.itemAt(0).title : "" + + spacing: Theme.space.xl + + Repeater { + id: repeater + model: root.model + delegate: Item { + id: surface + required property string surfaceId + required property string title + required property var document + required property int revision + + // revision is read so the node tree follows every patch. + readonly property var rootNode: revision >= 0 && document ? (document.root || document) : ({}) + // A document that is a section already brings its own card; + // anything else is put in one, titled with the surface. + readonly property bool ownCard: rootNode.type === "section" + + width: root.width + implicitHeight: ownCard ? bare.implicitHeight : card.implicitHeight + + PluginNode { + id: bare + visible: surface.ownCard + width: parent.width + surfaceId: surface.surfaceId + sink: root.model + node: surface.ownCard ? surface.rootNode : ({}) + } + + SettingsGroup { + id: card + visible: !surface.ownCard + width: parent.width + title: root.showTitles ? surface.title : "" + + PluginNode { + width: parent ? parent.width : 0 + property bool showDivider: false + inCard: true + surfaceId: surface.surfaceId + sink: root.model + node: surface.ownCard ? ({}) : surface.rootNode + } + } + } + } +} diff --git a/launcher/qml/Components/ProfileButton.qml b/launcher/qml/Components/ProfileButton.qml new file mode 100644 index 00000000..f41f0a85 --- /dev/null +++ b/launcher/qml/Components/ProfileButton.qml @@ -0,0 +1,166 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import MeshMC.Theme + +/* + * The account, top-right on every page: the default account's Minecraft + * face, its name and a chevron, opening ProfileMenu below it. With no + * account there is nothing to open -- a plain "Sign in" button goes straight + * to the Accounts page instead (see openAccountsRequested()), the same page + * ProfileMenu's own account actions land on. + */ +AbstractButton { + id: control + + property string name: "" + // "Microsoft", "Offline" or "" when there is no account. + property string kind: "" + property string avatarSource: "" + // AccountsController: accounts, setDefault, remove -- see ProfileMenu. + property var controller: null + + readonly property bool signedIn: control.name.length > 0 + readonly property int maxNameWidth: 130 + + signal openAccountsRequested() + + // For a dev-route snapshot ("profilemenu") that needs the dropdown open + // without a real click. + function openMenu() { if (control.signedIn) menu.open() } + + readonly property int hPadding: Theme.space.sm + 2 + implicitWidth: contentRow.implicitWidth + hPadding * 2 + implicitHeight: Theme.control.height + hoverEnabled: true + + Accessible.name: signedIn ? qsTr("Account menu: %1").arg(control.name) : qsTr("Sign in") + + onClicked: control.signedIn ? menu.toggle() : control.openAccountsRequested() + + background: Rectangle { + radius: Theme.radius.pill + color: control.down ? Theme.palette.pressedOverlay + : control.hovered || menu.visible ? Theme.palette.hoverOverlay : "transparent" + border.width: 1 + border.color: control.hovered || menu.visible ? Theme.palette.borderStrong : Theme.palette.border + + Behavior on color { ColorAnimation { duration: Theme.motion.fast; easing.type: Theme.motion.easing } } + Behavior on border.color { ColorAnimation { duration: Theme.motion.fast; easing.type: Theme.motion.easing } } + } + + // A plain Item, not the Row itself, is the contentItem: QQC2 stretches + // contentItem to fill the button's whole content box, and a Row whose + // own width is forced wider than its packed children just leaves the + // surplus as dead space after the last one (the chevron) -- exactly the + // gap this used to show. Centering the Row inside this wrapper instead + // keeps the Row itself sized to its content, so the pill hugs it. + contentItem: Item { + implicitWidth: contentRow.implicitWidth + implicitHeight: contentRow.implicitHeight + + Row { + id: contentRow + anchors.centerIn: parent + spacing: Theme.space.sm + + Item { + id: face + anchors.verticalCenter: parent.verticalCenter + visible: control.signedIn + width: Theme.control.height - 10 + height: width + + Rectangle { + anchors.fill: parent + // A small corner radius, not a full circle: the face + // image is a plain square (Qt Quick clip only clips to + // the bounding box, not to a rounded shape, so a fully + // round container just let the image's square corners + // poke out past it). A small radius keeps that overflow + // imperceptible instead of fighting a shape the image + // was never cut to. + radius: 6 + color: Theme.palette.accentSubtle + border.width: 1 + border.color: Theme.palette.border + + Text { + anchors.centerIn: parent + visible: control.signedIn + text: control.name.charAt(0).toUpperCase() + color: Theme.palette.accent + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + font.weight: Font.Bold + } + + // Drawn over the initial, not the other way around: an + // account without a skin (or one whose texture has not + // loaded yet) comes back transparent, and the initial + // shows through -- same idiom as the old sidebar chip + // this replaces. Sampled well above display size and + // downscaled by the GPU (smooth: false keeps that + // downscale crisp). + Image { + id: faceImage + anchors.fill: parent + anchors.margins: 1 + source: control.signedIn ? control.avatarSource : "" + visible: control.signedIn && control.avatarSource.length > 0 + smooth: false + sourceSize: Qt.size(64, 64) + } + } + } + + Text { + anchors.verticalCenter: parent.verticalCenter + visible: control.signedIn + width: Math.min(implicitWidth, control.maxNameWidth) + text: control.name + elide: Text.ElideRight + color: Theme.palette.textPrimary + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + font.weight: Font.DemiBold + } + + Text { + anchors.verticalCenter: parent.verticalCenter + visible: !control.signedIn + text: qsTr("Sign in") + color: Theme.palette.textPrimary + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + font.weight: Font.DemiBold + } + + MeshIcon { + anchors.verticalCenter: parent.verticalCenter + visible: control.signedIn + iconName: "chevron-down" + size: Theme.icon.sm + color: Theme.palette.textSecondary + rotation: menu.visible ? 180 : 0 + + Behavior on rotation { NumberAnimation { duration: Theme.motion.fast; easing.type: Theme.motion.easing } } + } + } + } + + ProfileMenu { + id: menu + x: control.width - width + y: control.height + Theme.space.xs + name: control.name + kind: control.kind + avatarSource: control.avatarSource + controller: control.controller + function toggle() { visible ? close() : open() } + onOpenAccountsRequested: control.openAccountsRequested() + } +} diff --git a/launcher/qml/Components/ProfileMenu.qml b/launcher/qml/Components/ProfileMenu.qml new file mode 100644 index 00000000..d77915fa --- /dev/null +++ b/launcher/qml/Components/ProfileMenu.qml @@ -0,0 +1,278 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import MeshMC.Theme + +/* + * The account dropdown opened from ProfileButton: who is playing, a quick + * switch to any other signed-in account, and the account actions that used + * to live at the foot of the sidebar. Everything that takes more than one + * click -- signing in, adding an offline account, managing skins -- goes to + * the Accounts page instead of repeating that flow here (see + * openAccountsRequested()); this menu only ever does the one-click things + * itself: switching the default account, and signing out of it. + */ +Popup { + id: root + + // AccountsController: accounts, setDefault(row), remove(row). + property var controller: null + property string name: "" + property string kind: "" + property string avatarSource: "" + + signal openAccountsRequested() + + readonly property var accounts: root.controller ? root.controller.accounts : null + + // Every other account's row and shown name, plus which row is the + // default (for "Sign out"), gathered the same way AccountsPage finds + // its own hero row: a zero-size probe per row rather than paging + // through the model by hand. + property var otherAccounts: [] + property int defaultRow: -1 + function rebuildAccounts() { + var rows = [] + var def = -1 + for (var i = 0; i < accountProbes.count; ++i) { + var probe = accountProbes.itemAt(i) + if (!probe) + continue + if (probe.isDefault) + def = probe.index + else + rows.push({ row: probe.index, + name: probe.profileName.length > 0 ? probe.profileName : probe.name, + accountId: probe.accountId, + isDefault: probe.isDefault }) + } + root.defaultRow = def + root.otherAccounts = rows + } + + Repeater { + id: accountProbes + model: root.accounts + delegate: Item { + id: probe + required property int index + required property bool isDefault + required property string profileName + required property string name + required property string accountId + visible: false + width: 0 + height: 0 + onIsDefaultChanged: root.rebuildAccounts() + onIndexChanged: root.rebuildAccounts() + Component.onCompleted: root.rebuildAccounts() + Component.onDestruction: Qt.callLater(root.rebuildAccounts) + } + } + + width: 300 + padding: Theme.space.xxs + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + + contentItem: ColumnLayout { + spacing: 0 + + RowLayout { + Layout.fillWidth: true + Layout.margins: Theme.space.sm + spacing: Theme.space.sm + + Rectangle { + Layout.preferredWidth: 40 + Layout.preferredHeight: 40 + radius: width / 2 + color: Theme.palette.accentSubtle + border.width: 1 + border.color: Theme.palette.border + + Text { + anchors.centerIn: parent + visible: root.name.length > 0 + text: root.name.charAt(0).toUpperCase() + color: Theme.palette.accent + font.family: Theme.font.family + font.pixelSize: Theme.type.title.pixelSize + font.weight: Font.Bold + } + // Drawn over the initial, not the other way around -- see + // ProfileButton's own face for why. + Image { + id: headerFace + anchors.fill: parent + anchors.margins: 1 + source: root.avatarSource + visible: root.avatarSource.length > 0 + smooth: false + sourceSize: Qt.size(80, 80) + } + } + + Column { + Layout.fillWidth: true + Layout.minimumWidth: 0 + spacing: 1 + + Text { + width: parent.width + text: root.name + elide: Text.ElideRight + color: Theme.palette.textPrimary + font.family: Theme.font.family + font.pixelSize: Theme.type.bodyStrong.pixelSize + font.weight: Theme.type.bodyStrong.weight + } + Text { + width: parent.width + text: root.kind === "Microsoft" ? qsTr("Microsoft account") + : root.kind === "Offline" ? qsTr("Offline account") : root.kind + elide: Text.ElideRight + color: Theme.palette.textTertiary + font.family: Theme.font.family + font.pixelSize: Theme.type.caption.pixelSize + } + } + } + + MenuSeparator { Layout.fillWidth: true; visible: root.otherAccounts.length > 0 } + + Text { + Layout.fillWidth: true + Layout.leftMargin: Theme.space.md + Layout.topMargin: Theme.space.xs + visible: root.otherAccounts.length > 0 + text: qsTr("SWITCH ACCOUNT") + color: Theme.palette.textTertiary + font.family: Theme.font.family + font.pixelSize: Theme.type.overline.pixelSize + font.weight: Theme.type.overline.weight + font.letterSpacing: Theme.type.overline.letterSpacing + } + + Repeater { + model: root.otherAccounts + delegate: ItemDelegate { + id: switchRow + required property var modelData + Layout.fillWidth: true + hoverEnabled: true + onClicked: { + if (root.controller) + root.controller.setDefault(switchRow.modelData.row) + root.close() + } + + // The row's own face, not ItemDelegate's alpha-recoloured + // icon slot -- a skin texture must never be tinted like a + // line icon. Same size and fallback-initial treatment as the + // header row's face above, so an account without a loaded + // texture reads the same way everywhere in this menu instead + // of leaving a blank gap. + contentItem: RowLayout { + spacing: Theme.space.sm + + Rectangle { + Layout.preferredWidth: 40 + Layout.preferredHeight: 40 + radius: width / 2 + color: Theme.palette.accentSubtle + border.width: 1 + border.color: Theme.palette.border + + Text { + anchors.centerIn: parent + visible: switchRow.modelData.name.length > 0 + text: switchRow.modelData.name.charAt(0).toUpperCase() + color: Theme.palette.accent + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + font.weight: Font.Bold + } + Image { + anchors.fill: parent + anchors.margins: 1 + source: switchRow.modelData.accountId.length > 0 + ? "image://accountface/" + switchRow.modelData.accountId : "" + visible: switchRow.modelData.accountId.length > 0 + smooth: false + sourceSize: Qt.size(80, 80) + } + } + Text { + Layout.fillWidth: true + Layout.minimumWidth: 0 + text: switchRow.modelData.name + elide: Text.ElideRight + color: Theme.palette.textPrimary + font.family: Theme.font.family + font.pixelSize: Theme.type.body.pixelSize + } + // Defensive only: otherAccounts excludes the default row + // by construction (see rebuildAccounts), so this never + // shows today -- but if it is ever listed, it should + // read as the active one rather than an identical, + // ambiguous row. + MeshIcon { + visible: switchRow.modelData.isDefault === true + iconName: "check" + size: Theme.icon.sm + color: Theme.palette.accent + } + } + } + } + + MenuSeparator { Layout.fillWidth: true } + + ItemDelegate { + Layout.fillWidth: true + hoverEnabled: true + text: qsTr("Skin and cape") + icon.source: Icons.url("image") + onClicked: { root.close(); root.openAccountsRequested() } + } + ItemDelegate { + Layout.fillWidth: true + hoverEnabled: true + text: qsTr("Manage accounts") + icon.source: Icons.url("users") + onClicked: { root.close(); root.openAccountsRequested() } + } + ItemDelegate { + Layout.fillWidth: true + hoverEnabled: true + text: qsTr("Add account") + icon.source: Icons.url("plus") + onClicked: { root.close(); root.openAccountsRequested() } + } + + MenuSeparator { Layout.fillWidth: true } + + ItemDelegate { + Layout.fillWidth: true + hoverEnabled: true + text: qsTr("Sign out") + icon.source: Icons.url("log-out") + onClicked: { + root.close() + signOutDialog.open() + } + } + } + + ConfirmDialog { + id: signOutDialog + title: qsTr("Sign out") + text: qsTr("Sign out of “%1”? You can sign in again at any time.").arg(root.name) + confirmText: qsTr("Sign out") + onConfirmed: if (root.controller && root.defaultRow >= 0) root.controller.remove(root.defaultRow) + } +} diff --git a/launcher/qml/Components/PromptDialog.qml b/launcher/qml/Components/PromptDialog.qml new file mode 100644 index 00000000..3f8284a5 --- /dev/null +++ b/launcher/qml/Components/PromptDialog.qml @@ -0,0 +1,126 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import MeshMC.Theme + +/* + * Asks for one line of text: a new name, a group. Enter confirms, Escape + * cancels; `suggestions` become one-click chips under the field (existing + * groups, say), so the common answers need no typing. + */ +Dialog { + id: root + + property string label + property string value + property string placeholder + property string confirmText: qsTr("Save") + property bool allowEmpty: false + property var suggestions: [] + property string error + signal submitted(string text) + + parent: Overlay.overlay + anchors.centerIn: parent + width: Math.min(440, parent ? parent.width - Theme.space.xxl * 2 : 440) + modal: true + + onOpened: { + field.text = root.value + root.error = "" + field.forceActiveFocus() + field.selectAll() + } + + function submit() { + var text = field.text.trim() + if (!root.allowEmpty && text.length === 0) + return + root.submitted(text) + } + + contentItem: Column { + spacing: Theme.space.sm + + Text { + visible: root.label.length > 0 + width: parent.width + text: root.label + wrapMode: Text.Wrap + color: Theme.palette.textSecondary + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + } + + TextField { + id: field + width: parent.width + placeholderText: root.placeholder + selectByMouse: true + onAccepted: root.submit() + } + + Flow { + width: parent.width + spacing: Theme.space.xs + visible: root.suggestions.length > 0 + Repeater { + model: root.suggestions + delegate: AbstractButton { + id: chip + required property string modelData + hoverEnabled: true + implicitHeight: Theme.control.heightSm + implicitWidth: chipText.implicitWidth + Theme.space.md * 2 + onClicked: field.text = modelData + background: Rectangle { + radius: height / 2 + color: chip.hovered ? Theme.palette.surfaceOverlay : Theme.palette.surfaceRaised + border.width: 1 + border.color: field.text === chip.modelData ? Theme.palette.accent : Theme.palette.border + } + contentItem: Text { + id: chipText + text: chip.modelData.length > 0 ? chip.modelData : qsTr("No group") + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + color: Theme.palette.textSecondary + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + } + } + } + } + + Text { + visible: root.error.length > 0 + width: parent.width + text: root.error + wrapMode: Text.Wrap + color: Theme.palette.danger + font.family: Theme.font.family + font.pixelSize: Theme.type.caption.pixelSize + } + } + + footer: Row { + layoutDirection: Qt.RightToLeft + spacing: Theme.space.sm + padding: Theme.space.lg + topPadding: 0 + Button { + text: root.confirmText + highlighted: true + enabled: root.allowEmpty || field.text.trim().length > 0 + onClicked: root.submit() + } + Button { + text: qsTr("Cancel") + flat: true + onClicked: root.close() + } + } +} diff --git a/launcher/qml/Components/RecentItem.qml b/launcher/qml/Components/RecentItem.qml new file mode 100644 index 00000000..76d62b7d --- /dev/null +++ b/launcher/qml/Components/RecentItem.qml @@ -0,0 +1,137 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import MeshMC.Theme + +/* + * One recently played instance in the sidebar: a click selects it, the + * play button that appears on hover launches it straight away. In the + * collapsed icon rail (`compact`) it shrinks to just the icon, with the + * name as a tooltip and the play button dropped -- there is no room left + * for it, and a click already opens the instance. + */ +AbstractButton { + id: control + + // Filled straight from the instance model's roles when used as a + // delegate; declared here rather than by the caller, since a caller + // redeclaring them would shadow these and leave this file reading blanks. + required property string instanceId + required property string name + required property string iconKey + required property bool isRunning + + property bool compact: false + + // Every decorative loop in the shell stops when the window is not the + // active one and when the user has asked for less motion -- repeated + // per file rather than centralised, since SettingsStore/Theme are not + // this change's to extend. + readonly property bool motionEnabled: Qt.application.state === Qt.ApplicationActive + && !SettingsStore.bool("UiReduceMotion") + + signal playRequested() + + implicitHeight: Theme.control.height + implicitWidth: 200 + hoverEnabled: true + + Accessible.name: name + + ToolTip.visible: control.compact && control.hovered + ToolTip.delay: 400 + ToolTip.text: control.name + + // Compact (icon rail): a square hugging just the icon, the same shape + // and size as NavItem's own rail square, so hover reads the same way on + // every item in the rail. + readonly property int railSquare: 40 + + background: Rectangle { + width: control.compact ? control.railSquare : control.width + height: control.compact ? control.railSquare : control.height + x: control.compact ? (control.width - width) / 2 : 0 + y: control.compact ? (control.height - height) / 2 : 0 + radius: Theme.radius.md + color: control.down ? Theme.palette.pressedOverlay + : control.hovered ? Theme.palette.hoverOverlay : "transparent" + Behavior on color { ColorAnimation { duration: Theme.motion.fast; easing.type: Theme.motion.easing } } + } + + contentItem: Item { + Item { + id: icon + anchors.left: control.compact ? undefined : parent.left + anchors.leftMargin: Theme.space.sm + anchors.horizontalCenter: control.compact ? parent.horizontalCenter : undefined + anchors.verticalCenter: parent.verticalCenter + width: Theme.icon.lg + height: Theme.icon.lg + + Image { + anchors.fill: parent + source: control.iconKey.length > 0 ? "image://instanceicon/" + control.iconKey : "" + sourceSize: Qt.size(width, height) + fillMode: Image.PreserveAspectFit + } + + // Pulses rather than sitting static, so a glance at the rail + // says "still running" instead of just "ran once". + Rectangle { + visible: control.isRunning + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: -2 + width: 8 + height: 8 + radius: 4 + color: Theme.palette.success + + SequentialAnimation on opacity { + running: control.isRunning && control.motionEnabled + loops: Animation.Infinite + NumberAnimation { from: 1.0; to: 0.45; duration: 900; easing.type: Easing.InOutSine } + NumberAnimation { from: 0.45; to: 1.0; duration: 900; easing.type: Easing.InOutSine } + } + } + } + + Text { + visible: !control.compact + anchors.left: icon.right + anchors.leftMargin: Theme.space.sm + 2 + anchors.right: trailing.left + anchors.rightMargin: Theme.space.xs + anchors.verticalCenter: parent.verticalCenter + text: control.name + elide: Text.ElideRight + color: control.hovered ? Theme.palette.textPrimary : Theme.palette.textSecondary + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + font.weight: Font.Medium + } + + Item { + id: trailing + visible: !control.compact + anchors.right: parent.right + anchors.rightMargin: Theme.space.xxs + anchors.verticalCenter: parent.verticalCenter + width: Theme.control.heightSm + height: width + + IconButton { + anchors.fill: parent + size: parent.width + visible: control.hovered && !control.isRunning + iconName: "play" + tip: qsTr("Play") + focusPolicy: Qt.NoFocus + onClicked: control.playRequested() + } + } + } +} diff --git a/launcher/qml/Components/ScreenshotsTab.qml b/launcher/qml/Components/ScreenshotsTab.qml new file mode 100644 index 00000000..b00114ae --- /dev/null +++ b/launcher/qml/Components/ScreenshotsTab.qml @@ -0,0 +1,175 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import MeshMC.Theme + +/* + * The instance's screenshots as a grid of thumbnails, newest first. + * Hovering one raises a scrim with its actions; clicking the image itself + * opens it in the system viewer. Thumbnails are decoded off the UI thread + * by the "screenshot" image provider. + */ +Item { + id: root + + // ScreenshotListModel: name, path, url, modified, size. + property var model: null + property string directory + signal openFolderRequested(string path) + + readonly property int tileWidth: 240 + readonly property int tileHeight: Math.round(tileWidth * 9 / 16) + + ColumnLayout { + anchors.fill: parent + spacing: Theme.space.md + + RowLayout { + Layout.fillWidth: true + Text { + Layout.fillWidth: true + text: root.model && root.model.count > 0 + ? qsTr("%1 screenshots").arg(root.model.count) : "" + color: Theme.palette.textTertiary + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + } + Button { + text: qsTr("Open folder") + icon.source: Icons.url("folder") + onClicked: root.openFolderRequested(root.directory) + } + } + + GridView { + id: grid + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + boundsBehavior: Flickable.StopAtBounds + model: root.model + readonly property int columns: Math.max(1, Math.floor(width / (root.tileWidth + Theme.space.md))) + cellWidth: Math.floor(width / columns) + cellHeight: root.tileHeight + Theme.space.xl + Theme.space.lg + ScrollBar.vertical: ScrollBar {} + + delegate: Item { + id: cell + required property int index + required property string name + required property string path + required property string url + required property var modified + + width: grid.cellWidth + height: grid.cellHeight + + HoverHandler { id: hover } + + Rectangle { + id: frame + x: (parent.width - width) / 2 + width: grid.cellWidth - Theme.space.md + height: root.tileHeight + radius: Theme.radius.md + color: Theme.palette.surfaceSunken + border.width: 1 + border.color: hover.hovered ? Theme.palette.borderStrong : Theme.palette.border + clip: true + + Behavior on border.color { ColorAnimation { duration: Theme.motion.fast } } + + Image { + id: thumb + anchors.fill: parent + anchors.margins: 1 + source: "image://screenshot/" + encodeURIComponent(cell.path) + sourceSize: Qt.size(512, 512) + fillMode: Image.PreserveAspectCrop + asynchronous: true + scale: hover.hovered ? 1.04 : 1.0 + Behavior on scale { NumberAnimation { duration: Theme.motion.normal; easing.type: Theme.motion.easing } } + } + + // A scrim over the foot of the thumbnail, dark regardless + // of theme (like Tag's onMedia mode), so the actions on + // top of it stay readable over any screenshot. + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: parent.height * 0.42 + opacity: hover.hovered ? 1 : 0 + Behavior on opacity { NumberAnimation { duration: Theme.motion.fast } } + gradient: Gradient { + GradientStop { position: 0.0; color: Qt.rgba(0, 0, 0, 0) } + GradientStop { position: 1.0; color: Qt.rgba(0, 0, 0, 0.55) } + } + + RowLayout { + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: Theme.space.xs + spacing: Theme.space.xxs + + IconButton { + size: Theme.control.heightSm + flat: false + iconName: "external-link" + tip: qsTr("Open") + onClicked: Qt.openUrlExternally(cell.url) + } + IconButton { + size: Theme.control.heightSm + flat: false + iconName: "trash" + tip: qsTr("Move to trash") + onClicked: if (root.model) root.model.remove(cell.index) + } + } + } + + TapHandler { onTapped: Qt.openUrlExternally(cell.url) } + } + + Column { + anchors.top: frame.bottom + anchors.topMargin: Theme.space.xs + x: frame.x + width: frame.width + spacing: 1 + + Text { + width: parent.width + text: cell.name + elide: Text.ElideMiddle + color: Theme.palette.textSecondary + font.family: Theme.font.family + font.pixelSize: Theme.type.caption.pixelSize + font.weight: Font.Medium + } + Text { + width: parent.width + text: Format.lastPlayed(cell.modified ? Number(cell.modified) : 0) + elide: Text.ElideRight + color: Theme.palette.textTertiary + font.family: Theme.font.family + font.pixelSize: Theme.type.caption.pixelSize + } + } + } + } + } + + EmptyState { + anchors.centerIn: parent + visible: !root.model || root.model.count === 0 + title: qsTr("No screenshots yet") + body: qsTr("Press F2 in game; they will show up here.") + MeshIcon { iconName: "image"; size: 40; color: Theme.palette.textTertiary } + } +} diff --git a/launcher/qml/Components/SearchBox.qml b/launcher/qml/Components/SearchBox.qml new file mode 100644 index 00000000..5c6e410c --- /dev/null +++ b/launcher/qml/Components/SearchBox.qml @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import MeshMC.Theme + +/* + * Not "SearchField": Qt 6.10 added a QtQuick.Controls.SearchField, which + * would win the name over this file wherever QtQuick.Controls is imported. + * + * TextField with a leading magnifier and a trailing clear button. Escape + * clears too, so a keyboard user can back out of a search without reaching + * for the mouse. + */ +TextField { + id: control + + leftPadding: Theme.space.md + Theme.icon.sm + Theme.space.sm + rightPadding: clearButton.visible ? clearButton.width + Theme.space.xs : Theme.space.md + implicitHeight: Theme.control.height + selectByMouse: true + + Keys.onEscapePressed: (event) => { + if (control.text.length > 0) { + control.clear() + event.accepted = true + } else { + event.accepted = false + } + } + + MeshIcon { + anchors.left: parent.left + anchors.leftMargin: Theme.space.md + anchors.verticalCenter: parent.verticalCenter + iconName: "search" + size: Theme.icon.sm + color: control.activeFocus ? Theme.palette.textSecondary : Theme.palette.textTertiary + } + + IconButton { + id: clearButton + anchors.right: parent.right + anchors.rightMargin: Theme.space.xxs + anchors.verticalCenter: parent.verticalCenter + size: Theme.control.heightSm + iconName: "x" + tip: qsTr("Clear search") + visible: control.text.length > 0 + focusPolicy: Qt.NoFocus + onClicked: control.clear() + } +} diff --git a/launcher/qml/Components/SectionHeader.qml b/launcher/qml/Components/SectionHeader.qml new file mode 100644 index 00000000..e9394004 --- /dev/null +++ b/launcher/qml/Components/SectionHeader.qml @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import MeshMC.Theme + +/* + * A group's title row: name, an optional item count, and a chevron that + * flips to show collapsed state. The chevron is drawn as rotated text + * rather than an image asset, since no icon asset pipeline exists yet. + */ +Item { + id: root + + property string title: "" + // -1 hides the count pill entirely, so callers that don't have a + // meaningful count (or don't want one shown) don't have to fake a zero. + property int count: -1 + property bool collapsed: false + property bool collapsible: true + + implicitHeight: Theme.control.heightSm + implicitWidth: row.implicitWidth + + Row { + id: row + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + spacing: Theme.space.xs + + Text { + anchors.verticalCenter: parent.verticalCenter + text: "❯" // "❯" + color: Theme.palette.textTertiary + font.pixelSize: Theme.type.caption.pixelSize + visible: root.collapsible + rotation: root.collapsed ? 0 : 90 + + Behavior on rotation { RotationAnimation { duration: Theme.motion.fast; easing.type: Theme.motion.easing } } + } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: root.title + color: Theme.palette.textSecondary + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + font.weight: Theme.type.label.weight + } + + Text { + anchors.verticalCenter: parent.verticalCenter + visible: root.count >= 0 + text: root.count + color: Theme.palette.textTertiary + font.family: Theme.font.family + font.pixelSize: Theme.type.caption.pixelSize + } + } + + MouseArea { + anchors.fill: parent + enabled: root.collapsible + cursorShape: root.collapsible ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: root.collapsed = !root.collapsed + } +} diff --git a/launcher/qml/Components/SegmentedControl.qml b/launcher/qml/Components/SegmentedControl.qml new file mode 100644 index 00000000..74e7490b --- /dev/null +++ b/launcher/qml/Components/SegmentedControl.qml @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import MeshMC.Theme + +/* + * A few mutually exclusive choices shown side by side, the current one + * raised -- for short option lists where a combo box would hide the + * alternatives behind a click. + */ +Rectangle { + id: root + + // [{ value, label }] + property var options: [] + property var current + signal activated(var value) + + implicitWidth: row.implicitWidth + 6 + implicitHeight: Theme.control.height + radius: Theme.radius.md + color: Theme.palette.surfaceSunken + border.width: 1 + border.color: Theme.palette.border + + Accessible.role: Accessible.PageTabList + + Row { + id: row + anchors.centerIn: parent + spacing: 2 + + Repeater { + model: root.options + delegate: AbstractButton { + id: segment + required property var modelData + readonly property bool selected: modelData.value === root.current + + height: root.height - 6 + width: label.implicitWidth + Theme.space.lg * 2 + hoverEnabled: true + checkable: true + checked: selected + Accessible.role: Accessible.PageTab + Accessible.name: modelData.label + onClicked: if (!selected) root.activated(modelData.value) + + background: Rectangle { + radius: Theme.radius.md - 2 + color: segment.selected ? Theme.palette.surfaceRaised + : segment.hovered ? Theme.palette.hoverOverlay : "transparent" + border.width: segment.selected ? 1 : 0 + border.color: Theme.palette.border + Behavior on color { ColorAnimation { duration: Theme.motion.fast } } + } + + contentItem: Text { + id: label + text: segment.modelData.label + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + color: segment.selected ? Theme.palette.textPrimary : Theme.palette.textSecondary + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + font.weight: segment.selected ? Font.DemiBold : Font.Medium + } + } + } + } +} diff --git a/launcher/qml/Components/ServersTab.qml b/launcher/qml/Components/ServersTab.qml new file mode 100644 index 00000000..ce805c44 --- /dev/null +++ b/launcher/qml/Components/ServersTab.qml @@ -0,0 +1,214 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import MeshMC.Theme + +/* + * This instance's servers.dat -- the widget-free replacement for + * ServersPage. Every field edits in place (no separate "selected server" + * side panel the way the widget had one, since a row's own fields are right + * there); "Join" launches the instance straight into that address. + */ +Item { + id: root + + // InstanceDetails.servers (ServersListModel) + serversDir. + property var model: null + property string serversDir: "" + readonly property bool unlocked: !!root.model && !root.model.locked + readonly property int count: list.count + signal openFolderRequested(string path) + signal joinRequested(string address) + + ColumnLayout { + anchors.fill: parent + spacing: Theme.space.md + + RowLayout { + Layout.fillWidth: true + spacing: Theme.space.sm + + Text { + Layout.fillWidth: true + text: !root.unlocked && !!root.model + ? qsTr("The game is running; the server list can be changed once it has closed.") + : list.count > 0 ? qsTr("%1 servers").arg(list.count) : "" + color: root.unlocked ? Theme.palette.textTertiary : Theme.palette.warning + elide: Text.ElideRight + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + } + Button { + text: qsTr("Open folder") + icon.source: Icons.url("folder") + onClicked: root.openFolderRequested(root.serversDir) + } + Button { + enabled: root.unlocked + text: qsTr("Add server") + icon.source: Icons.url("plus") + onClicked: { + if (!root.model) + return + var row = root.model.addServer() + if (row >= 0) + list.positionViewAtIndex(row, ListView.Contain) + } + } + } + + ListView { + id: list + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + spacing: Theme.space.sm + boundsBehavior: Flickable.StopAtBounds + model: root.model + ScrollBar.vertical: ScrollBar {} + + delegate: Rectangle { + id: row + required property int index + required property string name + required property string address + required property int acceptTextures + + width: list.width - Theme.space.md + height: Theme.control.heightLg * 2 + Theme.space.lg + radius: Theme.radius.lg + color: Theme.palette.surface + border.width: 1 + border.color: Theme.palette.border + + RowLayout { + anchors.fill: parent + anchors.margins: Theme.space.md + spacing: Theme.space.md + + Rectangle { + Layout.preferredWidth: 40 + Layout.preferredHeight: 40 + Layout.alignment: Qt.AlignTop + radius: Theme.radius.md + color: Theme.palette.surfaceSunken + MeshIcon { + anchors.centerIn: parent + iconName: "server" + size: Theme.icon.sm + color: Theme.palette.textTertiary + } + } + + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: Theme.space.xs + + TextField { + Layout.fillWidth: true + enabled: root.unlocked + text: row.name + placeholderText: qsTr("Name") + Accessible.name: qsTr("Server name") + onEditingFinished: if (root.model) root.model.setName(row.index, text) + } + TextField { + Layout.fillWidth: true + enabled: root.unlocked + text: row.address + placeholderText: qsTr("address:port") + Accessible.name: qsTr("Server address") + onEditingFinished: if (root.model) root.model.setAddress(row.index, text) + } + } + + ColumnLayout { + Layout.alignment: Qt.AlignTop + spacing: Theme.space.xs + + Text { + Layout.alignment: Qt.AlignRight + text: qsTr("Resource packs") + color: Theme.palette.textTertiary + font.family: Theme.font.family + font.pixelSize: Theme.type.caption.pixelSize + } + SegmentedControl { + Layout.alignment: Qt.AlignRight + enabled: root.unlocked + options: [ + { value: 0, label: qsTr("Ask") }, + { value: 1, label: qsTr("Always") }, + { value: 2, label: qsTr("Never") } + ] + current: row.acceptTextures + onActivated: (value) => { if (root.model) root.model.setAcceptTextures(row.index, value) } + } + } + + ColumnLayout { + Layout.alignment: Qt.AlignTop + spacing: Theme.space.xs + + RowLayout { + spacing: Theme.space.xs + IconButton { + iconName: "arrow-up" + tip: qsTr("Move up") + enabled: root.unlocked && row.index > 0 + onClicked: if (root.model) root.model.moveUp(row.index) + } + IconButton { + iconName: "arrow-down" + tip: qsTr("Move down") + enabled: root.unlocked && row.index < list.count - 1 + onClicked: if (root.model) root.model.moveDown(row.index) + } + IconButton { + iconName: "trash" + tip: qsTr("Remove server") + enabled: root.unlocked + onClicked: { + confirm.row = row.index + confirm.text = qsTr("Remove “%1” from this instance's server list?").arg(row.name.length > 0 ? row.name : row.address) + confirm.open() + } + } + } + Button { + Layout.alignment: Qt.AlignRight + text: qsTr("Join") + icon.source: Icons.url("play") + enabled: row.address.trim().length > 0 + onClicked: root.joinRequested(row.address) + } + } + } + } + } + } + + ConfirmDialog { + id: confirm + property int row: -1 + title: qsTr("Remove server") + confirmText: qsTr("Remove") + onConfirmed: if (root.model) root.model.removeServer(row) + } + + EmptyState { + anchors.centerIn: parent + upperThird: true + visible: list.count === 0 + title: qsTr("No servers yet") + body: qsTr("Servers this instance has joined show up here, or add one by hand.") + actionText: qsTr("Add server") + onActionTriggered: if (root.model) root.model.addServer() + MeshIcon { iconName: "server"; size: 40; color: Theme.palette.textTertiary } + } +} diff --git a/launcher/qml/Components/SettingChoice.qml b/launcher/qml/Components/SettingChoice.qml new file mode 100644 index 00000000..591aeb97 --- /dev/null +++ b/launcher/qml/Components/SettingChoice.qml @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick + +// A setting with a handful of named values, as a segmented control. +SettingRow { + id: root + + property string key + // Where the value lives; the launcher-wide settings unless told otherwise. + property var source: SettingsStore + // [{ value, label }] + property var options: [] + signal changed(var value) + + SegmentedControl { + options: root.options + current: root.source.string(root.key) + onActivated: (value) => { + root.source.setValue(root.key, value) + root.changed(value) + } + } +} diff --git a/launcher/qml/Components/SettingNumber.qml b/launcher/qml/Components/SettingNumber.qml new file mode 100644 index 00000000..8f3cfc0e --- /dev/null +++ b/launcher/qml/Components/SettingNumber.qml @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import MeshMC.Theme + +// A whole-number setting with bounds and a unit, saved on every change. +SettingRow { + id: root + + property string key + // Where the value lives; the launcher-wide settings unless told otherwise. + property var source: SettingsStore + property int from: 0 + property int to: 100 + property int stepSize: 1 + property string suffix + readonly property int current: root.source.number(root.key) + signal changed(int value) + + SpinBox { + id: control + width: 168 + from: root.from + to: root.to + stepSize: root.stepSize + editable: true + value: root.current + textFromValue: (value, locale) => Number(value).toLocaleString(locale, "f", 0) + + (root.suffix.length > 0 ? " " + root.suffix : "") + valueFromText: (text, locale) => { + var digits = text.replace(/[^0-9]/g, "") + return digits.length > 0 ? parseInt(digits, 10) : root.current + } + Accessible.name: root.label + onValueModified: { + root.source.setValue(root.key, value) + root.changed(value) + value = Qt.binding(() => root.current) + } + } +} diff --git a/launcher/qml/Components/SettingPathField.qml b/launcher/qml/Components/SettingPathField.qml new file mode 100644 index 00000000..6019115a --- /dev/null +++ b/launcher/qml/Components/SettingPathField.qml @@ -0,0 +1,107 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Dialogs +import MeshMC.Theme + +/* + * A path setting with a browse button, for the external tool installs and + * executables under Settings > External tools. Unlike SettingText's plain + * typed-in paths under General, these are picked from a native dialog, the + * same way ExternalToolsPage's "..." buttons work: `folder` picks a + * directory (JProfiler/MCEdit installs); otherwise it picks one file + * (JVisualVM/the JSON editor, both single executables). + * + * `toolId` ("jprofiler"/"jvisualvm"/"mcedit"), when set, also adds a Check + * button that runs SettingsAdapter::checkExternalTool() and shows the + * result underneath -- the QML equivalent of the classic page's own Check + * buttons. + */ +SettingRow { + id: root + + property string key + property var source: SettingsStore + property bool folder: false + property string placeholder + property var nameFilters: [qsTr("All files (*)")] + property string toolId: "" + wide: true + + property string checkResult: "" + property bool checkOk: false + + ColumnLayout { + width: parent.width + spacing: Theme.space.xs + + RowLayout { + Layout.fillWidth: true + spacing: Theme.space.sm + + TextField { + id: field + Layout.fillWidth: true + placeholderText: root.placeholder + selectByMouse: true + font.family: Theme.font.mono + Accessible.name: root.label + + readonly property string stored: root.source.string(root.key) + onStoredChanged: if (!activeFocus) text = stored + Component.onCompleted: text = stored + onEditingFinished: if (text !== stored) root.source.setValue(root.key, text) + } + IconButton { + iconName: "folder" + tip: qsTr("Browse…") + flat: false + onClicked: root.folder ? folderDialog.open() : fileDialog.open() + } + IconButton { + visible: root.toolId.length > 0 + iconName: "check" + tip: qsTr("Check") + flat: false + onClicked: { + const adapter = root.source.adapter + const error = adapter ? adapter.checkExternalTool(root.toolId, field.text) + : qsTr("Not available.") + root.checkResult = error.length === 0 ? qsTr("Looks good.") : error + root.checkOk = error.length === 0 + } + } + } + + Text { + visible: root.checkResult.length > 0 + Layout.fillWidth: true + wrapMode: Text.Wrap + text: root.checkResult + color: root.checkOk ? Theme.palette.success : Theme.palette.danger + font.family: Theme.font.family + font.pixelSize: Theme.type.caption.pixelSize + } + } + + FolderDialog { + id: folderDialog + currentFolder: field.text.length > 0 ? Format.fileUrl(field.text) : "" + onAccepted: { + field.text = Format.localPath(selectedFolder) + root.source.setValue(root.key, field.text) + } + } + FileDialog { + id: fileDialog + nameFilters: root.nameFilters + onAccepted: { + field.text = Format.localPath(selectedFile) + root.source.setValue(root.key, field.text) + } + } +} diff --git a/launcher/qml/Components/SettingRow.qml b/launcher/qml/Components/SettingRow.qml new file mode 100644 index 00000000..c82bfa13 --- /dev/null +++ b/launcher/qml/Components/SettingRow.qml @@ -0,0 +1,86 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Layouts +import MeshMC.Theme + +/* + * One setting: what it is and what it does on the left, its control on the + * right. `wide` puts the control under the text instead, for text fields + * that need the room. + */ +Item { + id: root + + property string label + property string description + property bool wide: false + property bool showDivider: false + default property alias control: controlSlot.data + + width: parent ? parent.width : implicitWidth + implicitHeight: Math.max(Theme.control.heightLg + Theme.space.md, + layout.implicitHeight + Theme.space.lg * 2) + opacity: enabled ? 1 : Theme.opacity.disabled + + Rectangle { + visible: root.showDivider + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: Theme.space.lg + anchors.rightMargin: Theme.space.lg + height: 1 + color: Theme.palette.divider + } + + GridLayout { + id: layout + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Theme.space.lg + anchors.rightMargin: Theme.space.lg + columns: root.wide ? 1 : 2 + columnSpacing: Theme.space.xl + rowSpacing: Theme.space.sm + + Column { + Layout.fillWidth: true + spacing: 2 + + Text { + width: parent.width + text: root.label + wrapMode: Text.Wrap + color: Theme.palette.textPrimary + font.family: Theme.font.family + font.pixelSize: Theme.type.body.pixelSize + font.weight: Font.Medium + } + + Text { + width: parent.width + visible: root.description.length > 0 + text: root.description + wrapMode: Text.Wrap + color: Theme.palette.textTertiary + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + lineHeight: 1.3 + } + } + + Item { + id: controlSlot + Layout.fillWidth: root.wide + Layout.alignment: Qt.AlignVCenter | (root.wide ? Qt.AlignLeft : Qt.AlignRight) + // A wide control takes the width the layout gives it, so it must + // not also report its own width back as the implicit one. + implicitWidth: root.wide ? 0 : childrenRect.width + implicitHeight: childrenRect.height + } + } +} diff --git a/launcher/qml/Components/SettingSwitch.qml b/launcher/qml/Components/SettingSwitch.qml new file mode 100644 index 00000000..9da533c7 --- /dev/null +++ b/launcher/qml/Components/SettingSwitch.qml @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls + +// An on/off setting, saved the moment it is flipped. `invert` is for +// settings stored as the opposite of what reads naturally in the UI. +SettingRow { + id: root + + property string key + // Where the value lives; the launcher-wide settings unless told otherwise. + property var source: SettingsStore + property bool invert: false + readonly property bool checked: root.invert !== root.source.bool(root.key) + // After the new value is stored; `on` is what the switch now shows. + signal switched(bool on) + + Switch { + id: control + checked: root.checked + Accessible.name: root.label + onToggled: { + root.source.setValue(root.key, root.invert ? !checked : checked) + // Toggling assigns `checked` and drops the binding; put it back + // so a change made elsewhere still shows here. + checked = Qt.binding(() => root.checked) + root.switched(checked) + } + } +} diff --git a/launcher/qml/Components/SettingText.qml b/launcher/qml/Components/SettingText.qml new file mode 100644 index 00000000..d04f0472 --- /dev/null +++ b/launcher/qml/Components/SettingText.qml @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import MeshMC.Theme + +/* + * A free-text setting. Saved when editing finishes (Enter or leaving the + * field), not per keystroke, so half-typed paths never reach the config. + */ +SettingRow { + id: root + + property string key + // Where the value lives; the launcher-wide settings unless told otherwise. + property var source: SettingsStore + property string placeholder + property bool monospace: false + property bool secret: false + wide: true + + TextField { + id: field + width: parent ? parent.width : 320 + placeholderText: root.placeholder + echoMode: root.secret ? TextInput.Password : TextInput.Normal + font.family: root.monospace ? Theme.font.mono : Theme.font.family + selectByMouse: true + Accessible.name: root.label + + readonly property string stored: root.source.string(root.key) + onStoredChanged: if (!activeFocus) text = stored + Component.onCompleted: text = stored + onEditingFinished: if (text !== stored) root.source.setValue(root.key, text) + } +} diff --git a/launcher/qml/Components/SettingsExternalToolsSection.qml b/launcher/qml/Components/SettingsExternalToolsSection.qml new file mode 100644 index 00000000..341a7012 --- /dev/null +++ b/launcher/qml/Components/SettingsExternalToolsSection.qml @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import MeshMC.Theme + +/* + * Profilers, MCEdit, and the editor used for JSON/text files -- the QML + * equivalent of the classic ExternalToolsPage. Each path is picked from a + * native dialog (see SettingPathField.qml) rather than typed by hand, and + * JProfiler/JVisualVM/MCEdit each get a Check button next to their path, + * same as the classic page. + */ +SettingsScroll { + title: qsTr("External tools") + description: qsTr("Profilers, MCEdit, and the editor used for JSON and text files.") + + SettingsGroup { + width: parent.width + title: qsTr("JProfiler") + description: qsTr("https://www.ej-technologies.com/products/jprofiler/overview.html") + SettingPathField { + key: "JProfilerPath" + label: qsTr("Install folder") + folder: true + toolId: "jprofiler" + } + } + + SettingsGroup { + width: parent.width + title: qsTr("JVisualVM") + description: qsTr("https://visualvm.github.io/") + SettingPathField { + key: "JVisualVMPath" + label: qsTr("Executable") + toolId: "jvisualvm" + } + } + + SettingsGroup { + width: parent.width + title: qsTr("MCEdit") + description: qsTr("https://www.mcedit.net/") + SettingPathField { + key: "MCEditPath" + label: qsTr("Install folder") + folder: true + toolId: "mcedit" + } + } + + SettingsGroup { + width: parent.width + title: qsTr("Editors") + description: qsTr("Leave empty to use the system default.") + SettingPathField { + key: "JsonEditor" + label: qsTr("Text editor") + placeholder: qsTr("Automatic") + } + } +} diff --git a/launcher/qml/Components/SettingsGroup.qml b/launcher/qml/Components/SettingsGroup.qml new file mode 100644 index 00000000..2fdef6d1 --- /dev/null +++ b/launcher/qml/Components/SettingsGroup.qml @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Layouts +import MeshMC.Theme + +/* + * A titled card of setting rows, separated by hairlines -- the layout every + * settings screen of a desktop app has converged on. + */ +Column { + id: root + + property string title + property string description + default property alias rows: rowsColumn.data + + spacing: Theme.space.sm + + Text { + visible: root.title.length > 0 + leftPadding: Theme.space.xs + text: root.title + color: Theme.palette.textPrimary + font.family: Theme.font.family + font.pixelSize: Theme.type.title.pixelSize + font.weight: Font.Bold + } + + Text { + visible: root.description.length > 0 + width: parent.width + leftPadding: Theme.space.xs + text: root.description + wrapMode: Text.Wrap + color: Theme.palette.textTertiary + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + } + + Rectangle { + width: parent.width + height: rowsColumn.height + radius: Theme.radius.lg + color: Theme.palette.surface + border.width: 1 + border.color: Theme.palette.border + + Column { + id: rowsColumn + width: parent.width + + // Hairline between rows, never above the first. + onChildrenChanged: root.markRows() + Component.onCompleted: root.markRows() + } + } + + function markRows() { + var first = true + for (var i = 0; i < rowsColumn.children.length; ++i) { + var row = rowsColumn.children[i] + if (row.showDivider === undefined) + continue + row.showDivider = !first && row.visible + if (row.visible) + first = false + } + } +} diff --git a/launcher/qml/Components/SettingsLogUploadSection.qml b/launcher/qml/Components/SettingsLogUploadSection.qml new file mode 100644 index 00000000..6fdbf880 --- /dev/null +++ b/launcher/qml/Components/SettingsLogUploadSection.qml @@ -0,0 +1,77 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import MeshMC.Theme + +/* + * The paste.ee key used for uploaded logs -- the QML equivalent of the + * classic PasteEEPage, which only ever has this one setting (PasteEEAPIKey: + * either the literal string "meshmc", or a key of the account's own). + * + * `ownKey` is local UI state, not a live binding to the stored setting: it + * only tracks which radio is selected, synced from the setting once at + * startup and from then on changed only by picking a radio here. Deriving + * it from the setting on every read instead would snap the "own key" radio + * back to "MeshMC key" the moment it is picked but before a key has been + * typed in, since the setting itself has not changed yet. + */ +SettingsScroll { + id: root + + title: qsTr("Log upload") + description: qsTr("paste.ee is used to share uploaded logs. Add your own API key to have uploads paired with your paste.ee account.") + + readonly property string meshmcKey: "meshmc" + property bool ownKey: SettingsStore.string("PasteEEAPIKey") !== root.meshmcKey + + SettingsGroup { + width: parent.width + title: qsTr("API key") + description: qsTr("Never shown again once set. Retype it to change it.") + + ButtonGroup { id: keyGroup } + + SettingRow { + wide: true + label: qsTr("MeshMC key") + description: qsTr("12 MB upload limit.") + RadioButton { + ButtonGroup.group: keyGroup + checked: !root.ownKey + text: qsTr("Use the MeshMC key") + onToggled: if (checked) { + root.ownKey = false + SettingsStore.setValue("PasteEEAPIKey", root.meshmcKey) + } + } + } + SettingRow { + wide: true + label: qsTr("Your own key") + description: qsTr("12 MB upload limit. Get one at paste.ee.") + ColumnLayout { + width: parent.width + spacing: Theme.space.xs + RadioButton { + ButtonGroup.group: keyGroup + checked: root.ownKey + text: qsTr("Use my own key") + onToggled: if (checked) root.ownKey = true + } + TextField { + Layout.fillWidth: true + visible: root.ownKey + echoMode: TextInput.Password + placeholderText: qsTr("Paste your API key here") + selectByMouse: true + onEditingFinished: if (text.trim().length > 0) + SettingsStore.setValue("PasteEEAPIKey", text.trim()) + } + } + } + } +} diff --git a/launcher/qml/Components/SettingsPage.qml b/launcher/qml/Components/SettingsPage.qml new file mode 100644 index 00000000..aec9809e --- /dev/null +++ b/launcher/qml/Components/SettingsPage.qml @@ -0,0 +1,527 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import MeshMC.Theme + +/* + * Launcher settings. Every change is saved the moment it is made -- there + * is no OK/Cancel, as in any current desktop app's preferences. + */ +Item { + id: root + + property int systemMemoryMiB: 8192 + // PluginSurfaceModel for the global settings anchor, or null. + property var pluginSurfaces: null + // TranslationsModel (languageKey, name, completeness) and the function + // that switches to one of its keys, live. + property var languages: null + property var selectLanguage: null + property string section: "general" + // Whether launcher/qml/Cat was built at all (Main.qml's own + // catAvailable, in turn MESHMC_HAS_CAT) -- hides the Cat group below + // rather than offering switches that would do nothing on a build + // without Qt Quick3D. + property bool catAvailable: false + + // One tile in the cat variant picker below -- the coat's face, cropped + // out of the same texture the 3D cat wears (see Cat/CatRig.qml), plus a label, + // styled like SkinCapeEditor's CapeTile but simple enough not to + // warrant its own file for the one row that uses it. + component CatVariantTile: Item { + id: tile + required property string value + required property string label + readonly property bool selected: SettingsStore.string("CatVariant") === tile.value + + implicitWidth: 56 + implicitHeight: 76 + + Rectangle { + id: swatch + anchors.top: parent.top + anchors.horizontalCenter: parent.horizontalCenter + width: 48 + height: 48 + radius: Theme.radius.md + color: tile.selected ? Theme.palette.accentSubtle : Theme.palette.surface + border.width: tile.selected ? 2 : 1 + border.color: tile.selected ? Theme.palette.accent : Theme.palette.border + + // The front of the head in the model's 64x64 UV layout: five + // pixels wide, four high, eyes on its second row. + Image { + anchors.centerIn: parent + width: 35 + height: 28 + smooth: false + sourceClipRect: Qt.rect(5, 33, 5, 4) + source: "qrc:/qt/qml/MeshMC/Cat/textures/cat_" + tile.value + ".png" + } + } + + Text { + anchors.top: swatch.bottom + anchors.topMargin: Theme.space.xs + anchors.horizontalCenter: parent.horizontalCenter + text: tile.label + color: Theme.palette.textSecondary + font.family: Theme.font.family + font.pixelSize: Theme.type.caption.pixelSize + } + + TapHandler { + onTapped: SettingsStore.setValue("CatVariant", tile.value) + } + } + + // No longer emitted from within this file -- proxy/external-tools/ + // log-upload all have QML sections of their own now (below) instead of + // a "More" escape hatch into the classic dialog. Left declared because + // Main.qml still binds a handler to it; removing the signal outright is + // for whoever next touches that file's own wiring. + signal openClassicRequested(string page) + signal openPathRequested(string path) + + readonly property var sections: [ + { id: "general", icon: "home", label: qsTr("General") }, + { id: "java", icon: "layers", label: qsTr("Java & memory") }, + { id: "minecraft", icon: "cube", label: qsTr("Minecraft") }, + { id: "console", icon: "terminal", label: qsTr("Console") }, + { id: "appearance", icon: "image", label: qsTr("Appearance") }, + { id: "commands", icon: "edit", label: qsTr("Custom commands") }, + { id: "plugins", icon: "package", label: qsTr("Plugins") }, + { id: "proxy", icon: "globe", label: qsTr("Proxy") }, + { id: "external-tools", icon: "settings", label: qsTr("External tools") }, + { id: "log-upload", icon: "copy", label: qsTr("Log upload") } + ] + readonly property int sectionIndex: { + for (var i = 0; i < sections.length; ++i) + if (sections[i].id === section) + return i + return 0 + } + + // A chrome-only screen (design-plan.md §5): no instance/pack art of its + // own to bleed behind the header, unlike Library/PlayDock/Discover's + // detail hero. The same quiet block-grid wash as Discover's own empty + // state, behind this page's header region; every SettingsGroup panel + // further down is a fully opaque surface and simply paints over it. + AmbientPattern { + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + height: 260 + fadeBottom: true + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: Theme.space.xl + Theme.space.xs + anchors.rightMargin: Theme.space.xl + Theme.space.xs + spacing: Theme.space.xl + + // Same rail selection grammar as the main sidebar (design-plan.md + // Principle 6/§4): a sliding pill + left accent bar behind the row, + // not just NavItem's own bare accent-icon/bold-text fallback. Shares + // SidebarNav's own mechanism via NavSelectionIndicator rather than + // re-deriving the geometry here. + Item { + id: sectionNavHost + Layout.alignment: Qt.AlignTop + Layout.preferredWidth: 208 + Layout.topMargin: Theme.space.xs + implicitHeight: sectionColumn.implicitHeight + + readonly property Item selectedItem: { + for (var i = 0; i < sectionRepeater.count; ++i) { + var item = sectionRepeater.itemAt(i) + if (item && item.modelData.id === root.section) + return item + } + return null + } + + NavSelectionIndicator { + target: sectionNavHost.selectedItem + } + + Column { + id: sectionColumn + width: parent.width + spacing: Theme.space.xxs + + Repeater { + id: sectionRepeater + model: root.sections + delegate: NavItem { + required property var modelData + width: parent.width + // Only offered when some plugin put something there. + visible: modelData.id !== "plugins" || pluginsView.count > 0 + iconName: modelData.icon + label: modelData.label + selected: root.section === modelData.id + onClicked: root.section = modelData.id + } + } + } + } + + StackLayout { + Layout.fillWidth: true + Layout.fillHeight: true + currentIndex: root.sectionIndex + + // General + SettingsScroll { + title: qsTr("General") + description: qsTr("How the library sorts and where new instances start out.") + SettingsGroup { + width: parent.width + title: qsTr("Library") + SettingChoice { + key: "InstSortMode" + label: qsTr("Sort instances by") + description: qsTr("Order inside each group of the library.") + options: [ + { value: "Name", label: qsTr("Name") }, + { value: "LastLaunch", label: qsTr("Last played") } + ] + } + } + SettingsGroup { + width: parent.width + title: qsTr("New instances") + SettingSwitch { + key: "DownloadGameFilesDuringInstanceCreation" + label: qsTr("Download game files right away") + description: qsTr("Fetch libraries and assets while the instance is created, instead of on its first launch.") + } + SettingSwitch { + key: "SkipModpackUpdatePrompt" + invert: true + label: qsTr("Offer to update an installed modpack") + description: qsTr("Installing a modpack you already have suggests updating that instance instead of making a second copy.") + } + SettingSwitch { + key: "BackupBeforeLaunch" + label: qsTr("Back up before every launch") + description: qsTr("Keeps a “pre-launch” snapshot in the instance's .backups folder.") + } + } + SettingsGroup { + width: parent.width + title: qsTr("Folders") + description: qsTr("Relative paths are inside MeshMC's data folder. Moving them is done in the classic settings, which check the new place first.") + Repeater { + model: [ + { key: "InstanceDir", label: qsTr("Instances") }, + { key: "CentralModsDir", label: qsTr("Mods") }, + { key: "IconsDir", label: qsTr("Icons") }, + { key: "SkinsDir", label: qsTr("Skins") }, + { key: "JavaDir", label: qsTr("Java runtimes") } + ] + delegate: SettingRow { + required property var modelData + label: modelData.label + description: SettingsStore.string(modelData.key) + IconButton { + iconName: "folder" + tip: qsTr("Open folder") + flat: false + onClicked: root.openPathRequested(SettingsStore.string(modelData.key)) + } + } + } + } + } + + // Java & memory + SettingsScroll { + title: qsTr("Java & memory") + description: qsTr("How much memory Minecraft gets, and which Java runtime runs it.") + SettingsGroup { + width: parent.width + title: qsTr("Memory") + MemorySetting { + label: qsTr("Maximum memory") + hint: qsTr("How much memory Minecraft may use. Big modpacks want 6–8 GiB; vanilla is happy with 2–4.") + systemMiB: root.systemMemoryMiB + } + SettingNumber { + key: "MinMemAlloc" + label: qsTr("Starting memory") + description: qsTr("Memory reserved when the game starts. Never above the maximum.") + from: 128 + to: SettingsStore.number("MaxMemAlloc") + stepSize: 128 + suffix: qsTr("MiB") + } + } + SettingsGroup { + width: parent.width + title: qsTr("Java runtime") + SettingSwitch { + key: "JavaAutoDownload" + label: qsTr("Download Java automatically") + description: qsTr("Fetches the Java version each Minecraft release needs, so you never have to install one yourself.") + } + SettingText { + key: "JavaPath" + label: qsTr("Java executable") + description: qsTr("Used when an instance does not choose its own. Leave empty to let MeshMC pick.") + placeholder: qsTr("Automatic") + monospace: true + } + SettingText { + key: "JvmArgs" + label: qsTr("JVM arguments") + description: qsTr("Extra flags for every launch. MeshMC already sets memory and the classpath.") + placeholder: "-XX:+UseG1GC" + monospace: true + } + } + } + + // Minecraft + SettingsScroll { + title: qsTr("Minecraft") + description: qsTr("The game window, play time tracking, and native library overrides.") + SettingsGroup { + width: parent.width + title: qsTr("Game window") + SettingSwitch { + id: maximized + key: "LaunchMaximized" + label: qsTr("Start maximized") + } + SettingNumber { + key: "MinecraftWinWidth" + enabled: !maximized.checked + label: qsTr("Window width") + from: 1 + to: 65536 + suffix: qsTr("px") + } + SettingNumber { + key: "MinecraftWinHeight" + enabled: !maximized.checked + label: qsTr("Window height") + from: 1 + to: 65536 + suffix: qsTr("px") + } + } + SettingsGroup { + width: parent.width + title: qsTr("Play time") + SettingSwitch { + key: "RecordGameTime" + label: qsTr("Record play time") + } + SettingSwitch { + key: "ShowGameTime" + label: qsTr("Show play time per instance") + } + SettingSwitch { + key: "ShowGlobalGameTime" + label: qsTr("Show total play time") + } + } + SettingsGroup { + width: parent.width + title: qsTr("Native libraries") + description: qsTr("Only for systems where the bundled libraries don't work, such as some Linux setups.") + SettingSwitch { + key: "UseNativeGLFW" + label: qsTr("Use the system's GLFW") + } + SettingSwitch { + key: "UseNativeOpenAL" + label: qsTr("Use the system's OpenAL") + } + } + } + + // Console + SettingsScroll { + title: qsTr("Console") + description: qsTr("What the game's log window does while you play, and how much of it is kept.") + SettingsGroup { + width: parent.width + title: qsTr("Game console") + SettingSwitch { + key: "ShowConsole" + label: qsTr("Show the console while playing") + } + SettingSwitch { + key: "AutoCloseConsole" + label: qsTr("Close the console when the game quits") + } + SettingSwitch { + key: "ShowConsoleOnError" + label: qsTr("Show the console when the game crashes") + } + } + SettingsGroup { + width: parent.width + title: qsTr("Log history") + SettingNumber { + key: "ConsoleMaxLines" + label: qsTr("Lines to keep") + from: 10000 + to: 1000000 + stepSize: 10000 + } + SettingSwitch { + key: "ConsoleOverflowStop" + label: qsTr("Stop logging when the limit is reached") + description: qsTr("Otherwise the oldest lines are dropped to make room.") + } + } + } + + // Appearance + SettingsScroll { + title: qsTr("Appearance") + description: qsTr("The launcher's own language and colour scheme.") + SettingsGroup { + width: parent.width + title: qsTr("Language") + SettingRow { + label: qsTr("Display language") + description: qsTr("Applied immediately.") + ComboBox { + id: languageBox + width: 260 + model: root.languages + textRole: "name" + valueRole: "languageKey" + enabled: !!root.languages && !!root.selectLanguage + function syncToSetting() { + currentIndex = indexOfValue(SettingsStore.string("Language")) + } + onCountChanged: syncToSetting() + Component.onCompleted: syncToSetting() + onActivated: root.selectLanguage(currentValue) + Accessible.name: qsTr("Display language") + } + } + } + SettingsGroup { + width: parent.width + title: qsTr("Theme") + SettingRow { + wide: true + label: qsTr("Palette") + description: qsTr("The launcher's colours. Each palette has a dark and a light variant.") + PalettePicker { + current: Theme.scheme + onPicked: (scheme) => { + Theme.scheme = scheme + SettingsStore.setValue("UiPalette", scheme) + } + } + } + SettingChoice { + key: "UiThemeMode" + label: qsTr("Mode") + showDivider: true + options: [ + { value: "dark", label: qsTr("Dark") }, + { value: "light", label: qsTr("Light") } + ] + onChanged: (value) => Theme.mode = value + } + } + SettingsGroup { + width: parent.width + title: qsTr("Motion") + SettingSwitch { + key: "UiReduceMotion" + label: qsTr("Reduce motion") + description: qsTr("Turns off decorative animation across the launcher, including the cat.") + } + } + // Hidden outright rather than shown disabled: a build + // without Qt Quick3D has nothing these switches could turn + // on, and a visible-but-inert row reads as broken, not as + // "not for you". See root.catAvailable's own comment. + SettingsGroup { + width: parent.width + visible: root.catAvailable + title: qsTr("Cat") + SettingSwitch { + key: "CatEnabled" + label: qsTr("Show the cat") + description: qsTr("A small Minecraft cat that lives on the play bar. Click to pet it, double-click and it hops.") + } + SettingRow { + wide: true + showDivider: true + label: qsTr("Variant") + Row { + spacing: Theme.space.md + CatVariantTile { value: "calico"; label: qsTr("Calico") } + CatVariantTile { value: "ginger"; label: qsTr("Ginger") } + CatVariantTile { value: "black"; label: qsTr("Black") } + CatVariantTile { value: "white"; label: qsTr("White") } + CatVariantTile { value: "siamese"; label: qsTr("Siamese") } + } + } + } + } + + // Custom commands + SettingsScroll { + title: qsTr("Custom commands") + description: qsTr("Shell commands run around every launch, launcher-wide.") + SettingsGroup { + width: parent.width + description: qsTr("Run around every launch. Available variables: $INST_NAME, $INST_ID, $INST_DIR, $INST_MC_DIR, $INST_JAVA and $INST_JAVA_ARGS.") + SettingText { + key: "PreLaunchCommand" + label: qsTr("Before launch") + monospace: true + } + SettingText { + key: "WrapperCommand" + label: qsTr("Wrapper") + description: qsTr("Runs the game through another program, such as prime-run or gamemoderun.") + monospace: true + } + SettingText { + key: "PostExitCommand" + label: qsTr("After exit") + monospace: true + } + } + } + + // Plugins + SettingsScroll { + title: qsTr("Plugins") + description: qsTr("Settings the installed plugins have added.") + PluginSurfaces { + id: pluginsView + width: parent.width + model: root.pluginSurfaces + } + } + + // Proxy + SettingsProxySection {} + + // External tools + SettingsExternalToolsSection {} + + // Log upload + SettingsLogUploadSection {} + } + } +} diff --git a/launcher/qml/Components/SettingsProxySection.qml b/launcher/qml/Components/SettingsProxySection.qml new file mode 100644 index 00000000..a928b7f4 --- /dev/null +++ b/launcher/qml/Components/SettingsProxySection.qml @@ -0,0 +1,107 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import MeshMC.Theme + +/* + * How MeshMC itself reaches the internet -- the QML equivalent of the + * classic ProxyPage. Every field saves as it is set, same as the rest of + * this window, so there is no separate Apply button; instead, whenever one + * of the five proxy settings changes, applyProxySettings() below pushes the + * whole configuration to the network layer immediately, the same effect + * ProxyPage::apply() had when the classic dialog's OK button was pressed. + */ +SettingsScroll { + id: root + + title: qsTr("Proxy") + description: qsTr("How MeshMC reaches the internet. Only MeshMC's own downloads use it; Minecraft ignores proxy settings.") + + readonly property string proxyType: SettingsStore.string("ProxyType") + readonly property bool needsAddress: root.proxyType === "SOCKS5" || root.proxyType === "HTTP" + + // A brief "Applied." confirmation after any push to the network layer, + // since there is no Apply button here for it to have been the result of. + property bool justApplied: false + Timer { + id: appliedTimer + interval: 1600 + onTriggered: root.justApplied = false + } + + Connections { + target: SettingsStore.adapter + function onValueChanged(id) { + switch (id) { + case "ProxyType": + case "ProxyAddr": + case "ProxyPort": + case "ProxyUser": + case "ProxyPass": + if (SettingsStore.adapter) { + SettingsStore.adapter.applyProxySettings( + SettingsStore.string("ProxyType"), + SettingsStore.string("ProxyAddr"), + SettingsStore.number("ProxyPort"), + SettingsStore.string("ProxyUser"), + SettingsStore.string("ProxyPass")) + root.justApplied = true + appliedTimer.restart() + } + break + } + } + } + + SettingsGroup { + width: parent.width + title: qsTr("Type") + description: root.justApplied ? qsTr("Applied.") + : qsTr("Changes apply immediately; there is no separate Apply button.") + SettingChoice { + key: "ProxyType" + label: qsTr("Proxy type") + options: [ + { value: "None", label: qsTr("None") }, + { value: "Default", label: qsTr("System") }, + { value: "SOCKS5", label: qsTr("SOCKS5") }, + { value: "HTTP", label: qsTr("HTTP") } + ] + } + } + + SettingsGroup { + width: parent.width + visible: root.needsAddress + title: qsTr("Address and port") + SettingText { + key: "ProxyAddr" + label: qsTr("Host") + placeholder: "127.0.0.1" + } + SettingNumber { + key: "ProxyPort" + label: qsTr("Port") + from: 1 + to: 65535 + } + } + + SettingsGroup { + width: parent.width + visible: root.needsAddress + title: qsTr("Authentication") + description: qsTr("Stored in plain text in MeshMC's configuration file.") + SettingText { + key: "ProxyUser" + label: qsTr("Username") + } + SettingText { + key: "ProxyPass" + label: qsTr("Password") + secret: true + } + } +} diff --git a/launcher/qml/Components/SettingsScroll.qml b/launcher/qml/Components/SettingsScroll.qml new file mode 100644 index 00000000..fba7904e --- /dev/null +++ b/launcher/qml/Components/SettingsScroll.qml @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import MeshMC.Theme + +// One settings section: a page heading (what this section is, and why you'd +// come here) over a scrolling column of groups, kept to a readable width on +// wide windows. +Flickable { + id: root + + property string title + property string description + default property alias groups: column.data + + contentWidth: width + contentHeight: column.height + Theme.space.xxl + boundsBehavior: Flickable.StopAtBounds + clip: true + Accessible.name: title + + ScrollBar.vertical: ScrollBar {} + + Column { + id: column + width: Math.min(root.width - Theme.space.md, 760) + y: Theme.space.xs + spacing: Theme.space.xl + + Column { + width: parent.width + spacing: Theme.space.xxs + + Text { + text: root.title + color: Theme.palette.textPrimary + font.family: Theme.font.family + font.pixelSize: Theme.type.heading.pixelSize + font.weight: Theme.type.heading.weight + } + + Text { + visible: root.description.length > 0 + width: parent.width + text: root.description + wrapMode: Text.Wrap + color: Theme.palette.textSecondary + font.family: Theme.font.family + font.pixelSize: Theme.type.body.pixelSize + lineHeight: 1.3 + } + } + } +} diff --git a/launcher/qml/Components/SettingsSource.qml b/launcher/qml/Components/SettingsSource.qml new file mode 100644 index 00000000..e17cd442 --- /dev/null +++ b/launcher/qml/Components/SettingsSource.qml @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick + +/* + * What setting rows read and write through: one SettingsAdapter (the + * launcher's, or one instance's). `revision` moves on every change, so a + * binding that reads value() through here re-evaluates when anything -- + * this page, the widget dialog, a plugin -- changes a setting. + */ +QtObject { + id: store + + property var adapter: null + property int revision: 0 + + readonly property Connections watcher: Connections { + target: store.adapter + ignoreUnknownSignals: true + function onValueChanged() { store.revision++ } + } + + function value(key) { + // Reading revision makes every caller's binding depend on it. + return store.revision >= 0 && store.adapter ? store.adapter.value(key) : undefined + } + + // Settings read back from the config file can be strings ("false" is + // truthy in JS), so rows go through these instead of value() directly. + function bool(key) { + var v = store.value(key) + return v === true || v === "true" || v === 1 || v === "1" + } + + function number(key) { + var n = Number(store.value(key)) + return isNaN(n) ? 0 : n + } + + function string(key) { + var v = store.value(key) + return v === undefined || v === null ? "" : String(v) + } + + function setValue(key, value) { + if (store.adapter) + store.adapter.setValue(key, value) + } + + function reset(key) { + if (store.adapter) + store.adapter.reset(key) + } + + function isDefault(key) { + return store.revision >= 0 && store.adapter + ? String(store.adapter.value(key)) === String(store.adapter.defaultValue(key)) + : true + } +} diff --git a/launcher/qml/Components/SettingsStore.qml b/launcher/qml/Components/SettingsStore.qml new file mode 100644 index 00000000..e09e2515 --- /dev/null +++ b/launcher/qml/Components/SettingsStore.qml @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +pragma Singleton + +import QtQuick + +// The launcher-wide settings; the shell sets `adapter` once at startup. +// Setting rows use this unless they are given another `source`. +SettingsSource {} diff --git a/launcher/qml/Components/SidebarNav.qml b/launcher/qml/Components/SidebarNav.qml new file mode 100644 index 00000000..c499ff77 --- /dev/null +++ b/launcher/qml/Components/SidebarNav.qml @@ -0,0 +1,240 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import MeshMC.Theme + +/* + * The left rail: brand, the main destinations, the last few instances + * played, and the account. Pages that open elsewhere (Settings, for now a + * dialog) are still listed here -- where they open is the shell's business, + * not the rail's. + * + * Below `collapseWidth` the rail folds down to an icon-only strip (labels + * become tooltips); the caller drives that from the window's own width via + * `collapsed`, since only it knows what the window is competing for space + * with. + */ +Rectangle { + id: root + + // Each entry: { id, icon, label }; icon is a MeshIcon name. + property var items: [] + property var footerItems: [] + property string currentId: "" + + // Any model with instanceId/name/iconKey/isRunning roles; only the + // first `recentLimit` rows are shown. + property var recentModel: null + property int recentLimit: 4 + + // The account chip used to live at the foot of this rail; it is gone + // now that the top bar's ProfileButton covers every page (including the + // instance page, which had no chip to show it on). These three stay + // declared, unused, only because Gallery.qml -- outside this change -- + // still binds them on its own SidebarNav preview. + property string accountName: "" + property string accountKind: "" + property string accountAvatarSource: "" + + property bool collapsed: false + readonly property int expandedWidth: 244 + readonly property int railWidth: 72 + + signal itemActivated(string id) + signal recentActivated(string id) + signal recentPlayRequested(string id) + signal accountClicked() + + // The item (from either nav Repeater) whose id matches currentId, or + // null on a page (Accounts) neither one lists -- activeIndicator just + // hides itself then, rather than parking on the last real destination. + readonly property Item selectedNavItem: findNavItem(currentId) + + function findNavItem(id) { + if (!id || id.length === 0) + return null + for (var i = 0; i < navRepeater.count; ++i) { + var item = navRepeater.itemAt(i) + if (item && item.modelData.id === id) + return item + } + for (var j = 0; j < footerRepeater.count; ++j) { + var footerItem = footerRepeater.itemAt(j) + if (footerItem && footerItem.modelData.id === id) + return footerItem + } + return null + } + + implicitWidth: collapsed ? railWidth : expandedWidth + color: Theme.palette.surface + + Behavior on implicitWidth { NumberAnimation { duration: Theme.motion.normal; easing.type: Theme.motion.easing } } + + // Hairline against the page, instead of a contrasting fill. + Rectangle { + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom + width: 1 + color: Theme.palette.divider + } + + // The selected destination's fill, as one pill that slides from the + // previous item to the new one instead of each item owning its own + // static highlight -- NavItem itself only ever paints hover/press. + // The shared mechanism (also used by Settings' own section list) lives + // in NavSelectionIndicator.qml; collapsed mode's centred square instead + // of a full-width bar is documented there. + readonly property int railSquare: 40 + + NavSelectionIndicator { + target: root.selectedNavItem + collapsed: root.collapsed + railSquare: root.railSquare + insetX: Theme.space.md + insetY: Theme.space.md + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: Theme.space.md + anchors.rightMargin: Theme.space.md + 1 + spacing: Theme.space.xs + + // Brand + RowLayout { + Layout.fillWidth: !root.collapsed + Layout.alignment: root.collapsed ? Qt.AlignHCenter : (Qt.AlignLeft | Qt.AlignVCenter) + Layout.preferredHeight: Theme.control.heightLg + Layout.leftMargin: root.collapsed ? 0 : Theme.space.xs + Layout.bottomMargin: Theme.space.md + spacing: Theme.space.sm + 2 + + Image { + readonly property int extent: Theme.control.heightSm + 2 + Layout.preferredWidth: extent + Layout.preferredHeight: extent + source: "qrc:/icons/multimc/scalable/instances/meshmc.svg" + // From a constant, not from width: the layout sizes this + // item from its implicit size, which sourceSize sets. + sourceSize: Qt.size(extent * 2, extent * 2) + fillMode: Image.PreserveAspectFit + } + + Text { + visible: !root.collapsed + text: "MeshMC" + color: Theme.palette.textPrimary + font.family: Theme.font.family + font.pixelSize: Theme.type.title.pixelSize + 2 + font.weight: Font.Bold + font.letterSpacing: -0.2 + } + + Item { Layout.fillWidth: !root.collapsed } + } + + Repeater { + id: navRepeater + model: root.items + delegate: NavItem { + required property var modelData + Layout.fillWidth: true + iconName: modelData.icon + label: modelData.label + selected: modelData.id === root.currentId + collapsed: root.collapsed + onClicked: root.itemActivated(modelData.id) + } + } + + // A quiet rule instead of a second "RECENT"-style label: the + // section below already names itself, this just says where the + // destinations end. + Rectangle { + Layout.fillWidth: true + Layout.topMargin: Theme.space.md + Layout.bottomMargin: Theme.space.xxs + visible: recentRepeater.count > 0 + height: 1 + color: Theme.palette.divider + } + + Text { + Layout.topMargin: Theme.space.sm + Layout.leftMargin: Theme.space.md + Layout.bottomMargin: Theme.space.xs + visible: recentRepeater.count > 0 && !root.collapsed + text: qsTr("RECENT") + color: Theme.palette.textTertiary + font.family: Theme.font.family + font.pixelSize: Theme.type.overline.pixelSize + font.weight: Theme.type.overline.weight + font.letterSpacing: Theme.type.overline.letterSpacing + } + + Repeater { + id: recentRepeater + model: root.recentModel + delegate: RecentItem { + required property int index + + // Rows past the limit stay out of the layout entirely. + visible: index < root.recentLimit + Layout.fillWidth: true + Layout.topMargin: root.collapsed && index === 0 ? Theme.space.xs : 0 + compact: root.collapsed + onClicked: root.recentActivated(instanceId) + onPlayRequested: root.recentPlayRequested(instanceId) + } + } + + Item { Layout.fillHeight: true } + + Rectangle { + Layout.fillWidth: true + Layout.bottomMargin: Theme.space.xs + height: 1 + color: Theme.palette.divider + } + + Repeater { + id: footerRepeater + model: root.footerItems + delegate: NavItem { + required property var modelData + Layout.fillWidth: true + iconName: modelData.icon + label: modelData.label + selected: modelData.id === root.currentId + collapsed: root.collapsed + onClicked: root.itemActivated(modelData.id) + } + } + + // The collapse toggle, grouped with Settings at the sidebar's foot + // rather than floating in its own row up near the logo -- persisted, + // so a manual choice survives a restart; SidebarNav.collapsed itself + // still ORs this with the caller's own narrow-window check (see + // Main.qml), so a small window keeps auto-collapsing regardless of + // what was last chosen. + IconButton { + id: collapseToggle + Layout.topMargin: Theme.space.xxs + Layout.alignment: root.collapsed ? Qt.AlignHCenter : Qt.AlignRight + size: Theme.control.heightSm + iconName: "chevron-left" + tip: root.collapsed ? qsTr("Expand sidebar") : qsTr("Collapse sidebar") + rotation: root.collapsed ? 180 : 0 + + Behavior on rotation { NumberAnimation { duration: Theme.motion.normal; easing.type: Theme.motion.easing } } + + onClicked: SettingsStore.setValue("UiSidebarCollapsed", !SettingsStore.bool("UiSidebarCollapsed")) + } + } +} diff --git a/launcher/qml/Components/Skeleton.qml b/launcher/qml/Components/Skeleton.qml new file mode 100644 index 00000000..f608a95f --- /dev/null +++ b/launcher/qml/Components/Skeleton.qml @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import MeshMC.Theme + +/* + * A shimmering placeholder block for content still loading -- a flat tinted + * rectangle with a soft highlight band sweeping across it on a loop. No + * shader (the Qt floor here is 6.4): the "shine" is an ordinary horizontal + * Gradient translated by a NumberAnimation, clipped to this item's bounds. + * + * Generic on purpose, so any area can drop it in wherever a spinner would + * otherwise sit -- a card grid (see ModpackCard's `skeleton` mode), a list + * row, a single line of text. + */ +Item { + id: root + + property int radius: Theme.radius.md + + // Every decorative loop in the shell stops when the window is not the + // active one and when the user has asked for less motion. + readonly property bool motionEnabled: Qt.application.state === Qt.ApplicationActive + && !SettingsStore.bool("UiReduceMotion") + + clip: true + + Rectangle { + anchors.fill: parent + radius: root.radius + color: Theme.palette.surfaceSunken + } + + Rectangle { + id: sweep + width: Math.max(1, root.width) * 0.4 + height: parent.height + radius: root.radius + // Transparent -> a token's own colour -> transparent, so the sweep + // reads as a highlight in both themes without a literal colour. + gradient: Gradient { + orientation: Gradient.Horizontal + GradientStop { position: 0.0; color: Qt.rgba(Theme.palette.surfaceOverlay.r, Theme.palette.surfaceOverlay.g, Theme.palette.surfaceOverlay.b, 0) } + GradientStop { position: 0.5; color: Qt.rgba(Theme.palette.surfaceOverlay.r, Theme.palette.surfaceOverlay.g, Theme.palette.surfaceOverlay.b, 0.85) } + GradientStop { position: 1.0; color: Qt.rgba(Theme.palette.surfaceOverlay.r, Theme.palette.surfaceOverlay.g, Theme.palette.surfaceOverlay.b, 0) } + } + + SequentialAnimation on x { + loops: Animation.Infinite + running: root.visible + NumberAnimation { from: -sweep.width; to: root.width; duration: 1300; easing.type: Easing.InOutSine } + PauseAnimation { duration: 450 } + } + } +} diff --git a/launcher/qml/Components/SkinCapeEditor.qml b/launcher/qml/Components/SkinCapeEditor.qml new file mode 100644 index 00000000..366cb6ee --- /dev/null +++ b/launcher/qml/Components/SkinCapeEditor.qml @@ -0,0 +1,272 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Dialogs +import MeshMC.Theme + +/* + * Skin and cape editor for one Microsoft account, opened from AccountsPage. + * Everything here calls back into AccountsController -- changeSkin()/ + * resetSkin()/changeCape()/accountSkinInfo()/validateSkinFile() -- which + * drives the same three services (SkinUpload/SkinDelete/CapeChange) the + * classic SkinManageDialog used, minus its local skin library: this picks a + * file and uploads it directly (with whichever arm width is currently + * selected) rather than keeping a gallery of skins to switch between. + * + * One instance is shared by the whole page (see AccountsPage.qml); `row` is + * set right before open() for whichever account was clicked. + */ +Dialog { + id: root + parent: Overlay.overlay + anchors.centerIn: parent + width: 520 + modal: true + title: root.accountName.length > 0 ? qsTr("Skin & cape — %1").arg(root.accountName) + : qsTr("Skin & cape") + + // AccountsController: accountSkinInfo, validateSkinFile, changeSkin, + // resetSkin, changeCape. + property var controller: null + property int row: -1 + property string accountId: "" + property string accountName: "" + // Bumped by AccountsPage whenever an account's data may have changed, + // so the body image's url changes and QML actually refetches it -- see + // AccountsPage.qml's imageRevision. + property int rev: 0 + + // Snapshot from controller.accountSkinInfo(row) -- re-read on open and + // whenever the account list reports a change (a refresh landing). + property var info: emptyInfo() + function emptyInfo() { + return { valid: false, slim: false, currentCapeId: "", capes: [] } + } + function reload() { + // Not gated on row >= 0: row -1 is the qml-preview-tools demo + // sentinel (see AccountsController::skinDemoRequested()), which + // accountSkinInfo() already reports as "invalid" the rest of the + // time, so nothing else here needs to know the sentinel exists. + root.info = root.controller + ? root.controller.accountSkinInfo(root.row) : root.emptyInfo() + } + + // The arm width a picked file uploads with. A plain property, not a + // binding to info.slim: it has to survive info being re-read (a + // response to something unrelated changing elsewhere) without + // clobbering a choice the user just made but has not uploaded yet. Set + // from info.slim once, on open, same as any other field here. + property bool slim: false + + // Whichever change is running, if any -- shared by upload/reset/cape so + // only one can run at a time and its status/error show the same way. + property var watcher: null + readonly property bool busy: !!root.watcher && root.watcher.running + property string pickError: "" + // Same per-account fallback tint as AccountsPage's own hero stage + // (design-plan.md §5/§9) -- this stage duplicates that idiom + // independently, so it needs the same fix, not just the hero card. + readonly property color stageTint: Format.hashTint(root.accountId) + + onOpened: { + reload() + root.slim = root.info.slim + pickError = "" + watcher = null + } + + Connections { + target: root.controller ? root.controller.accounts : null + function onDataChanged() { root.reload() } + } + + function runWatcher(w) { + if (!w) + return + root.watcher = w + w.finished.connect(function() { root.reload() }) + } + + contentItem: ColumnLayout { + spacing: Theme.space.lg + + RowLayout { + Layout.fillWidth: true + spacing: Theme.space.lg + + // The stage: a floor shadow and either the account's real + // skin body or a neutral, per-account-tinted silhouette + // standing on it -- the same idiom AccountsPage's own hero card + // uses, scaled down to fit here (design-plan.md §5/§9). + Item { + id: stage + Layout.preferredWidth: 132 + Layout.preferredHeight: 208 + Layout.alignment: Qt.AlignTop + + // Soft floor shadow the figure appears to stand on, matching + // AccountsPage's hero exactly -- a flattened pill, not a + // glow behind the figure (design-plan.md "no glows"). + Rectangle { + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + width: 96; height: 16 + radius: height / 2 + color: Format.shade(root.stageTint, Theme.dark ? 0.45 : 0.55, 0.6) + opacity: 0.30 + } + + SkinSilhouette { + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + anchors.bottomMargin: 10 + unit: 0.87 + opacity: 0.65 + color: Format.shade(root.stageTint, Theme.dark ? 0.62 : 0.42, 0.5) + } + + Image { + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + width: 76 + height: 152 + fillMode: Image.PreserveAspectFit + smooth: false + source: root.accountId.length > 0 ? "image://accountface/body/" + root.accountId + "?rev=" + root.rev : "" + sourceSize: Qt.size(152, 304) + } + } + + ColumnLayout { + Layout.fillWidth: true + Layout.alignment: Qt.AlignTop + spacing: Theme.space.md + + Text { + text: qsTr("Arm width") + color: Theme.palette.textSecondary + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + } + SegmentedControl { + options: [ + { value: "classic", label: qsTr("Classic") }, + { value: "slim", label: qsTr("Slim") } + ] + current: root.slim ? "slim" : "classic" + onActivated: (value) => root.slim = value === "slim" + } + + RowLayout { + Layout.topMargin: Theme.space.xs + spacing: Theme.space.sm + Button { + highlighted: true + text: qsTr("Change skin…") + enabled: !root.busy + onClicked: skinFileDialog.open() + } + Button { + flat: true + text: qsTr("Reset skin") + enabled: !root.busy + onClicked: root.runWatcher(root.controller.resetSkin(root.row)) + } + } + + RowLayout { + Layout.fillWidth: true + visible: root.busy || root.pickError.length > 0 || (!!root.watcher && root.watcher.failed) + spacing: Theme.space.sm + + BusyIndicator { + visible: root.busy + running: visible + width: 20; height: 20 + } + Text { + Layout.fillWidth: true + wrapMode: Text.Wrap + color: (root.pickError.length > 0 || (!!root.watcher && root.watcher.failed)) + ? Theme.palette.danger : Theme.palette.textTertiary + text: root.pickError.length > 0 ? root.pickError + : root.busy ? (root.watcher.status.length > 0 ? root.watcher.status : qsTr("Working…")) + : (root.watcher ? root.watcher.error : "") + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + } + } + + // Fills the space below a short status line so the actions + // above stay top-aligned with the stage instead of centring + // in whatever height the capes row below ends up needing. + Item { Layout.fillHeight: true } + } + } + + Rectangle { Layout.fillWidth: true; height: 1; color: Theme.palette.divider } + + Text { + text: qsTr("Capes") + color: Theme.palette.textPrimary + font.family: Theme.font.family + font.pixelSize: Theme.type.title.pixelSize + font.weight: Font.Bold + } + + Flow { + Layout.fillWidth: true + spacing: Theme.space.sm + + CapeTile { + label: qsTr("No cape") + isNoCapeOption: true + selected: root.info.currentCapeId.length === 0 + enabled: !root.busy + onClicked: root.runWatcher(root.controller.changeCape(root.row, "")) + } + Repeater { + model: root.info.capes + delegate: CapeTile { + required property var modelData + label: modelData.alias + capeUrl: modelData.url + selected: modelData.id === root.info.currentCapeId + enabled: !root.busy + onClicked: root.runWatcher(root.controller.changeCape(root.row, modelData.id)) + } + } + } + } + + footer: Row { + layoutDirection: Qt.RightToLeft + spacing: Theme.space.sm + padding: Theme.space.lg + topPadding: 0 + Button { + text: qsTr("Close") + onClicked: root.close() + } + } + + FileDialog { + id: skinFileDialog + title: qsTr("Select skin texture") + nameFilters: [qsTr("PNG images (*.png)")] + onAccepted: { + const path = selectedFile.toString() + const error = root.controller ? root.controller.validateSkinFile(path) : "" + if (error.length > 0) { + root.pickError = error + } else { + root.pickError = "" + root.runWatcher(root.controller.changeSkin(root.row, path, root.slim)) + } + } + } +} diff --git a/launcher/qml/Components/SkinSilhouette.qml b/launcher/qml/Components/SkinSilhouette.qml new file mode 100644 index 00000000..cee65981 --- /dev/null +++ b/launcher/qml/Components/SkinSilhouette.qml @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import MeshMC.Theme + +/* + * A neutral player figure (head, body, two legs) shown under a skin render + * while it loads, or in place of one for an account with no skin -- tinted + * per account by the caller (design-plan.md §9). `unit` scales the whole + * figure; at 1 it is 64x162. + */ +Item { + id: root + + property color color: Theme.palette.textTertiary + property real unit: 1 + + implicitWidth: 64 * root.unit + implicitHeight: 162 * root.unit + + Rectangle { + anchors.horizontalCenter: parent.horizontalCenter + y: 0 + width: 30 * root.unit + height: width + radius: width / 2 + color: root.color + } + Rectangle { + anchors.horizontalCenter: parent.horizontalCenter + y: 34 * root.unit + width: 42 * root.unit + height: 58 * root.unit + radius: Theme.radius.lg + color: root.color + } + Repeater { + // Left and right leg, offset from the figure's centre line. + model: [-19, 3] + Rectangle { + required property int modelData + x: root.width / 2 + modelData * root.unit + y: 94 * root.unit + width: 16 * root.unit + height: 68 * root.unit + radius: Theme.radius.sm + color: root.color + } + } +} diff --git a/launcher/qml/Components/StatTile.qml b/launcher/qml/Components/StatTile.qml new file mode 100644 index 00000000..c36aaf34 --- /dev/null +++ b/launcher/qml/Components/StatTile.qml @@ -0,0 +1,86 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import MeshMC.Theme + +// One number worth seeing at a glance: a label over a big value. +Item { + id: root + + property string label + property string value + property string iconName + + implicitWidth: 180 + implicitHeight: 88 + + // A faint duplicate a few pixels below the card reads as a soft drop + // shadow without a real blur (none of the effect modules are available + // at this Qt floor). + Rectangle { + x: 0 + y: 3 + width: parent.width + height: parent.height + radius: Theme.radius.lg + color: Theme.palette.scrim + opacity: 0.08 + } + + Rectangle { + anchors.fill: parent + radius: Theme.radius.lg + color: Theme.palette.surface + border.width: 1 + border.color: Theme.palette.border + } + + Column { + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Theme.space.lg + anchors.rightMargin: Theme.space.lg + spacing: Theme.space.sm + + Row { + spacing: Theme.space.xs + 2 + + Rectangle { + anchors.verticalCenter: parent.verticalCenter + visible: root.iconName.length > 0 + width: Theme.icon.md + Theme.space.xs + height: width + radius: Theme.radius.sm + 2 + color: Theme.palette.accentSubtle + MeshIcon { + anchors.centerIn: parent + iconName: root.iconName + size: Theme.icon.sm + color: Theme.palette.accentText + } + } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: root.label + color: Theme.palette.textTertiary + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + font.weight: Font.Medium + } + } + + Text { + width: parent.width + text: root.value.length > 0 ? root.value : "—" + elide: Text.ElideRight + color: Theme.palette.textPrimary + font.family: Theme.font.family + font.pixelSize: Theme.type.heading.pixelSize + font.weight: Font.Bold + } + } +} diff --git a/launcher/qml/Components/StatusBadge.qml b/launcher/qml/Components/StatusBadge.qml new file mode 100644 index 00000000..d2516f18 --- /dev/null +++ b/launcher/qml/Components/StatusBadge.qml @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import MeshMC.Theme + +/* + * Small text pill for a status word ("Beta", "Modded", "Update available"...). + * "neutral" has no matching pair in the token contract (every other tone is + * /Subtle), so it falls back to the general-purpose + * surfaceOverlay/textSecondary tokens rather than a made-up colour. + */ +Rectangle { + id: root + + property string text: "" + property string tone: "neutral" + + function toneTextColor() { + switch (root.tone) { + case "success": return Theme.palette.success + case "warning": return Theme.palette.warning + case "danger": return Theme.palette.danger + case "info": return Theme.palette.info + default: return Theme.palette.textSecondary + } + } + + function toneBackgroundColor() { + switch (root.tone) { + case "success": return Theme.palette.successSubtle + case "warning": return Theme.palette.warningSubtle + case "danger": return Theme.palette.dangerSubtle + case "info": return Theme.palette.infoSubtle + default: return Theme.palette.surfaceOverlay + } + } + + radius: Theme.radius.pill + color: root.toneBackgroundColor() + implicitWidth: label.implicitWidth + Theme.space.sm * 2 + implicitHeight: label.implicitHeight + Theme.space.xxs * 2 + + Text { + id: label + anchors.centerIn: parent + text: root.text + color: root.toneTextColor() + font.family: Theme.font.family + font.pixelSize: Theme.type.caption.pixelSize + font.weight: Theme.type.caption.weight + } +} diff --git a/launcher/qml/Components/TabStrip.qml b/launcher/qml/Components/TabStrip.qml new file mode 100644 index 00000000..c244b744 --- /dev/null +++ b/launcher/qml/Components/TabStrip.qml @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import MeshMC.Theme + +/* + * Page tabs: labels on a hairline, the current one underlined in the + * accent. For switching views of one thing (an instance's mods, worlds, + * log...), where the sidebar's pill style would read as navigation away. + */ +Item { + id: root + + // [{ id, label, count? }] + property var tabs: [] + property string current + signal activated(string id) + + implicitHeight: Theme.control.heightLg + implicitWidth: row.implicitWidth + + Accessible.role: Accessible.PageTabList + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 1 + color: Theme.palette.divider + } + + Row { + id: row + height: parent.height + spacing: Theme.space.xl + + Repeater { + model: root.tabs + delegate: AbstractButton { + id: tab + required property var modelData + readonly property bool selected: modelData.id === root.current + + height: row.height + hoverEnabled: true + Accessible.role: Accessible.PageTab + Accessible.name: modelData.label + onClicked: root.activated(modelData.id) + + contentItem: Row { + spacing: Theme.space.xs + 2 + Text { + anchors.verticalCenter: parent.verticalCenter + text: tab.modelData.label + color: tab.selected ? Theme.palette.textPrimary + : tab.hovered ? Theme.palette.textSecondary : Theme.palette.textTertiary + font.family: Theme.font.family + font.pixelSize: Theme.type.body.pixelSize + font.weight: tab.selected ? Font.DemiBold : Font.Medium + } + Text { + anchors.verticalCenter: parent.verticalCenter + visible: tab.modelData.count !== undefined && tab.modelData.count >= 0 + text: tab.modelData.count !== undefined ? tab.modelData.count : "" + color: Theme.palette.textTertiary + font.family: Theme.font.family + font.pixelSize: Theme.type.caption.pixelSize + font.weight: Font.Medium + } + } + + background: Item { + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 2 + // xs (2) fully rounds a 2px-tall bar the same as the + // old literal 1 did; token, not a magic number. + radius: Theme.radius.xs + color: Theme.palette.accent + visible: tab.selected + } + Rectangle { + anchors.fill: parent + anchors.margins: -2 + radius: Theme.radius.sm + color: "transparent" + border.width: 2 + border.color: Theme.palette.focusRing + visible: tab.visualFocus + } + } + } + } + } +} diff --git a/launcher/qml/Components/Tag.qml b/launcher/qml/Components/Tag.qml new file mode 100644 index 00000000..3181d013 --- /dev/null +++ b/launcher/qml/Components/Tag.qml @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import MeshMC.Theme + +/* + * A small neutral pill for facts about something ("Fabric", "1.21.4", + * "9"). StatusBadge is the coloured, stateful sibling; this one never + * signals state, so it has no tone. + */ +Rectangle { + id: root + + property string text + property string iconName + // On a tinted or image background the default fill would vanish; this + // lets such a caller ask for a darker, see-through pill instead. + property bool onMedia: false + + implicitWidth: row.implicitWidth + Theme.space.sm * 2 + implicitHeight: Theme.control.heightSm - Theme.space.xs + radius: Theme.radius.sm + 2 + color: root.onMedia ? Theme.media.chip : Theme.palette.surfaceOverlay + border.width: 1 + border.color: root.onMedia ? Theme.media.chipBorder : Theme.palette.border + + Row { + id: row + anchors.centerIn: parent + spacing: Theme.space.xs + + MeshIcon { + anchors.verticalCenter: parent.verticalCenter + visible: root.iconName.length > 0 + iconName: root.iconName + size: Theme.icon.sm - 2 + color: label.color + } + + Text { + id: label + anchors.verticalCenter: parent.verticalCenter + text: root.text + color: root.onMedia ? Theme.media.text : Theme.palette.textSecondary + font.family: Theme.font.family + font.pixelSize: Theme.type.caption.pixelSize + font.weight: Font.Medium + } + } +} diff --git a/launcher/qml/Components/Toast.qml b/launcher/qml/Components/Toast.qml new file mode 100644 index 00000000..d8642dea --- /dev/null +++ b/launcher/qml/Components/Toast.qml @@ -0,0 +1,133 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import MeshMC.Theme + +/* + * A short note at the bottom of the window that goes away by itself -- + * "Instance deleted", "Copy failed: ..." -- for outcomes worth saying but + * not worth a dialog. show() replaces whatever is showing. + */ +Rectangle { + id: root + + property string tone: "neutral" // neutral | success | danger + property alias text: label.text + property string actionText: "" + property var actionCallback: null + + // Drives every animated property below; show()/hide() only ever flip + // this, so the slide+fade always run the same way in both directions. + property bool shown: false + + // @p actionLabel/@p callback are optional -- most calls just say + // something happened and are left with no button at all. + function show(message, messageTone, actionLabel, callback) { + label.text = message + root.tone = messageTone || "neutral" + root.actionText = actionLabel || "" + root.actionCallback = callback || null + root.shown = true + hideTimer.restart() + } + + function hide() { + hideTimer.stop() + root.shown = false + } + + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + anchors.bottomMargin: Theme.space.xl + width: Math.min(row.implicitWidth + Theme.space.lg * 2, parent.width - Theme.space.xxl * 2) + height: Theme.control.heightLg + radius: Theme.radius.lg + color: Theme.palette.surfaceOverlay + border.width: 1 + border.color: Theme.palette.borderStrong + // Without this the accent bar's square corners would poke out past + // root's own rounded ones. + clip: true + opacity: shown ? 1 : 0 + visible: opacity > 0 + z: 1000 + + // Slides up out of the bottom margin as it fades in, and back down as + // it fades out. + transform: Translate { + y: root.shown ? 0 : Theme.space.md + root.height * 0.15 + Behavior on y { NumberAnimation { duration: Theme.motion.normal; easing.type: Theme.motion.easing } } + } + Behavior on opacity { NumberAnimation { duration: Theme.motion.normal; easing.type: Theme.motion.easing } } + + Accessible.role: Accessible.AlertMessage + Accessible.name: label.text + + Timer { + id: hideTimer + interval: root.tone === "danger" ? 6000 : 3000 + onTriggered: root.shown = false + } + + readonly property color toneColor: tone === "danger" ? Theme.palette.danger + : tone === "success" ? Theme.palette.success : Theme.palette.accent + + Rectangle { + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + width: 4 + color: root.toneColor + } + + Row { + id: row + anchors.centerIn: parent + anchors.horizontalCenterOffset: Theme.space.xs + spacing: Theme.space.sm + + MeshIcon { + anchors.verticalCenter: parent.verticalCenter + iconName: root.tone === "danger" ? "alert-triangle" : root.tone === "success" ? "check" : "info" + size: Theme.icon.sm + 2 + color: root.toneColor + } + Text { + id: label + anchors.verticalCenter: parent.verticalCenter + color: Theme.palette.textPrimary + font.family: Theme.font.family + font.pixelSize: Theme.type.body.pixelSize + font.weight: Font.Medium + elide: Text.ElideRight + } + + AbstractButton { + id: actionButton + visible: root.actionText.length > 0 + anchors.verticalCenter: parent.verticalCenter + hoverEnabled: true + leftPadding: Theme.space.sm + implicitHeight: label.implicitHeight + + contentItem: Text { + text: root.actionText + color: actionButton.hovered ? Theme.palette.accentHover : Theme.palette.accent + font.family: Theme.font.family + font.pixelSize: Theme.type.body.pixelSize + font.weight: Font.DemiBold + } + background: Item {} + + onClicked: { + var callback = root.actionCallback + root.hide() + if (callback) + callback() + } + } + } +} diff --git a/launcher/qml/Components/TopBar.qml b/launcher/qml/Components/TopBar.qml new file mode 100644 index 00000000..8fc08a43 --- /dev/null +++ b/launcher/qml/Components/TopBar.qml @@ -0,0 +1,140 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import MeshMC.Theme + +/* + * Page header: title with an optional count and the account on one row, + * a search field and whatever actions the page passes in as children on the + * one below. It sits on the page itself rather than on its own band, so the + * page reads as one surface. + */ +Item { + id: root + + property string title: "" + property int count: -1 + property string searchText: "" + property string searchPlaceholder: qsTr("Search") + property bool searchVisible: true + + // The account, on the title row's right, vertically centred on the + // title -- the same spot on every page (see ProfileButton). + // AccountsController: accounts, setDefault, remove. + property string accountName: "" + property string accountKind: "" + property string accountAvatarSource: "" + property var accountsController: null + // "Skin and cape", "Manage accounts", "Add account" and the signed-out + // "Sign in" button all land on the same place -- the Accounts page, + // which already has the add/sign-in flow this never reimplements. + signal openAccountsRequested() + + default property alias actions: actionsRow.data + + readonly property int titleRowHeight: Theme.control.height + readonly property int toolbarRowHeight: Theme.control.heightLg + // The toolbar row collapses to nothing on a page with no search/actions + // (Settings, Discover, Accounts) rather than leaving an empty gap under + // the title -- same total height those pages had before the account + // moved out of their toolbar row and in with the title. + implicitHeight: Theme.space.sm + titleRowHeight + + (root.searchVisible ? Theme.space.xs + toolbarRowHeight : 0) + + Theme.space.sm + + function focusSearch() { + searchField.forceActiveFocus() + searchField.selectAll() + } + + // For a dev-route snapshot ("profilemenu") that needs the dropdown open + // without a real click. + function openProfileMenu() { profileButton.openMenu() } + + ColumnLayout { + anchors.fill: parent + anchors.leftMargin: Theme.space.xl + Theme.space.xs + anchors.rightMargin: Theme.space.xl + Theme.space.xs + anchors.topMargin: Theme.space.sm + anchors.bottomMargin: Theme.space.sm + spacing: Theme.space.xs + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: root.titleRowHeight + spacing: Theme.space.md + + Text { + Layout.alignment: Qt.AlignVCenter + text: root.title + color: Theme.palette.textPrimary + font.family: Theme.font.family + font.pixelSize: Theme.type.heading.pixelSize + 4 + font.weight: Font.Bold + font.letterSpacing: -0.3 + } + + Tag { + Layout.alignment: Qt.AlignVCenter + visible: root.count >= 0 + text: root.count + } + + Item { Layout.fillWidth: true } + + ProfileButton { + id: profileButton + Layout.alignment: Qt.AlignVCenter + name: root.accountName + kind: root.accountKind + avatarSource: root.accountAvatarSource + controller: root.accountsController + onOpenAccountsRequested: root.openAccountsRequested() + } + } + + // The search field gives up its width first under pressure -- + // sort/view/New instance keep their own size, since shrinking a + // combo box or a labelled button reads as broken where a narrower + // search field still reads as a search field. + RowLayout { + visible: root.searchVisible + Layout.fillWidth: true + Layout.preferredHeight: root.toolbarRowHeight + spacing: Theme.space.md + + SearchBox { + id: searchField + Layout.fillWidth: true + Layout.minimumWidth: 120 + Layout.maximumWidth: 300 + placeholderText: root.searchPlaceholder + + // TextField.text is a plain notifying property, not a + // bindable one -- typing into it would otherwise permanently + // sever a plain "text: root.searchText" binding. Both + // directions are wired explicitly instead so external resets + // of searchText still take. + onTextChanged: if (text !== root.searchText) root.searchText = text + Component.onCompleted: text = root.searchText + + Connections { + target: root + function onSearchTextChanged() { + if (searchField.text !== root.searchText) + searchField.text = root.searchText + } + } + } + + Row { + id: actionsRow + spacing: Theme.space.sm + } + } + } +} diff --git a/launcher/qml/Components/UiRequestDialog.qml b/launcher/qml/Components/UiRequestDialog.qml new file mode 100644 index 00000000..968904ee --- /dev/null +++ b/launcher/qml/Components/UiRequestDialog.qml @@ -0,0 +1,445 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Dialogs as NativeDialogs +import MeshMC.Theme + +/* + * Answers the questions the core asks while it works -- "overwrite this?", + * "which of these?", "these mods must be downloaded by hand" -- in the + * shell's own style instead of widget message boxes. `host` is the + * shell's QmlUiHost; whenever it has a pending request, this opens on it, + * and closing it any way other than a button counts as "no". + * + * The root is a plain Item, not the Dialog itself: kind == "filePicker" + * answers through a native QtQuick.Dialogs file dialog instead of this + * component's own chrome (there is no sensible way to draw an OS file + * picker inside a themed Dialog body), so the two live as siblings here, + * both reacting to the same `host`. + */ +Item { + id: wrapper + + property var host: null + + Dialog { + id: root + + readonly property var request: wrapper.host ? wrapper.host.current : null + readonly property string kind: request ? request.kind : "" + + function severityIcon(severity) { + switch (severity) { + case "error": return "alert-triangle" + case "warning": return "alert-triangle" + case "question": return "info" + default: return "info" + } + } + function severityColor(severity) { + switch (severity) { + case "error": return Theme.palette.danger + case "warning": return Theme.palette.warning + default: return Theme.palette.accent + } + } + + readonly property bool allFound: { + if (!request || kind !== "blockedMods") + return false + var mods = request.blockedMods + for (var i = 0; i < mods.length; ++i) + if (!mods[i].found) + return false + return true + } + + // Set before a button answers, so onClosed does not answer twice. + property bool answered: false + + parent: Overlay.overlay + anchors.centerIn: parent + width: Math.min(kind === "blockedMods" || kind === "untrustedMods" || kind === "update" ? 620 : 460, + parent ? parent.width - Theme.space.xxl * 2 : 460) + modal: true + closePolicy: Popup.CloseOnEscape + title: request ? request.title : "" + + header: DialogHeader { + title: root.title + icon: root.request ? root.severityIcon(root.request.severity) : "" + iconColor: root.request ? root.severityColor(root.request.severity) : Theme.palette.accent + } + + onRequestChanged: { + // filePicker answers through the native FileDialog below instead + // of this Dialog's own body -- see the file-level comment. + if (kind === "filePicker") { + return + } + if (request) { + answered = false + open() + } else if (opened) { + close() + } + } + onClosed: { + if (request && !answered) { + answered = true + request.reject() + } + } + + function answer(fn) { + root.answered = true + fn() + } + + contentItem: ColumnLayout { + spacing: Theme.space.md + + // The severity icon now leads the header badge instead of sitting + // beside the body text, so this is just the text at full width. + Text { + Layout.fillWidth: true + text: root.request ? root.request.text : "" + textFormat: Text.AutoText + wrapMode: Text.Wrap + color: Theme.palette.textSecondary + linkColor: Theme.palette.accent + font.family: Theme.font.family + font.pixelSize: Theme.type.body.pixelSize + lineHeight: 1.3 + onLinkActivated: (link) => Qt.openUrlExternally(link) + } + + // text: one line to type, prefilled with the suggested answer. + TextField { + id: textAnswer + Layout.fillWidth: true + visible: root.kind === "text" + selectByMouse: true + onAccepted: if (text.trim().length > 0) root.answer(() => root.request.accept(text.trim())) + Connections { + target: root + function onRequestChanged() { + if (root.kind === "text") { + textAnswer.text = root.request.value || "" + textAnswer.forceActiveFocus() + textAnswer.selectAll() + } + } + } + } + + // Blocked mods: each file, whether it is already downloaded, and + // a way to its download page. The folder is watched in C++. + Column { + Layout.fillWidth: true + visible: root.kind === "blockedMods" + spacing: Theme.space.xs + Repeater { + model: root.kind === "blockedMods" && root.request ? root.request.blockedMods : [] + delegate: Rectangle { + required property var modelData + required property int index + width: parent.width + height: Theme.control.heightLg + radius: Theme.radius.md + color: Theme.palette.surfaceRaised + Row { + anchors.left: parent.left + anchors.leftMargin: Theme.space.md + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.space.sm + MeshIcon { + anchors.verticalCenter: parent.verticalCenter + iconName: modelData.found ? "check" : "download" + size: Theme.icon.sm + color: modelData.found ? Theme.palette.success : Theme.palette.textTertiary + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: modelData.fileName + color: Theme.palette.textPrimary + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + } + } + Button { + anchors.right: parent.right + anchors.rightMargin: Theme.space.xs + anchors.verticalCenter: parent.verticalCenter + visible: !modelData.found + flat: true + text: qsTr("Download") + icon.source: Icons.url("external-link") + onClicked: root.request.openDownload(index) + } + } + } + Button { + flat: true + text: qsTr("Check again") + icon.source: Icons.url("refresh") + onClicked: root.request.rescanDownloads() + } + } + + // Untrusted files: what would be written, and a deliberate pause + // before it can be accepted. + Column { + Layout.fillWidth: true + visible: root.kind === "untrustedMods" + spacing: Theme.space.sm + Rectangle { + width: parent.width + height: Math.min(180, fileList.contentHeight + Theme.space.md) + radius: Theme.radius.md + color: Theme.palette.surfaceSunken + ListView { + id: fileList + anchors.fill: parent + anchors.margins: Theme.space.sm + clip: true + model: root.kind === "untrustedMods" && root.request ? root.request.untrustedModsFiles : [] + delegate: Text { + required property string modelData + width: fileList.width + text: modelData + elide: Text.ElideMiddle + color: Theme.palette.textSecondary + font.family: Theme.font.mono + font.pixelSize: Theme.type.caption.pixelSize + } + } + } + CheckBox { + id: trustBox + text: qsTr("I trust these files") + enabled: !trustDelay.running + Timer { + id: trustDelay + interval: root.request ? root.request.confirmDelayMs : 0 + } + Connections { + target: root + function onRequestChanged() { + trustBox.checked = false + if (root.kind === "untrustedMods") + trustDelay.restart() + } + } + } + } + + // Profile setup: a Microsoft account that owns Minecraft but has + // never picked a username -- checked live against Mojang's API, + // the way the widget ProfileSetupDialog does. + Column { + Layout.fillWidth: true + visible: root.kind === "profileSetup" + spacing: Theme.space.sm + + TextField { + id: profileNameField + width: parent.width + placeholderText: qsTr("Username") + selectByMouse: true + enabled: !(root.request && root.request.profileSubmitting) + onTextChanged: profileCheckDelay.restart() + Timer { + id: profileCheckDelay + interval: 500 + onTriggered: if (root.kind === "profileSetup" && root.request) + root.request.checkProfileName(profileNameField.text.trim()) + } + Connections { + target: root + function onRequestChanged() { + if (root.kind === "profileSetup") { + profileNameField.text = "" + profileNameField.forceActiveFocus() + } + } + } + } + + Row { + spacing: Theme.space.xs + visible: root.request && (root.request.profileNameStatus === "available" + || root.request.profileNameError.length > 0) + MeshIcon { + anchors.verticalCenter: parent.verticalCenter + iconName: root.request && root.request.profileNameStatus === "available" ? "check" : "alert-triangle" + size: Theme.icon.sm + color: root.request && root.request.profileNameStatus === "available" ? Theme.palette.success : Theme.palette.danger + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: root.request ? (root.request.profileNameStatus === "available" + ? qsTr("Available") : root.request.profileNameError) : "" + color: Theme.palette.textSecondary + font.family: Theme.font.family + font.pixelSize: Theme.type.caption.pixelSize + } + } + } + + // Update: what changes, in the author's words. + Column { + Layout.fillWidth: true + visible: root.kind === "update" + spacing: Theme.space.sm + Row { + spacing: Theme.space.sm + Tag { + text: root.request && root.request.updateInfo ? root.request.updateInfo.currentVersion || "" : "" + } + MeshIcon { + anchors.verticalCenter: parent.verticalCenter + iconName: "chevron-right" + size: Theme.icon.sm + } + Tag { + text: root.request && root.request.updateInfo ? root.request.updateInfo.availableVersion || "" : "" + iconName: "download" + } + } + ScrollView { + width: parent.width + height: Math.min(260, notes.implicitHeight + Theme.space.md) + visible: notes.text.length > 0 + Text { + id: notes + width: parent.width + text: root.request && root.request.updateInfo ? root.request.updateInfo.releaseNotes || "" : "" + textFormat: Text.MarkdownText + wrapMode: Text.Wrap + color: Theme.palette.textSecondary + linkColor: Theme.palette.accent + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + onLinkActivated: (link) => Qt.openUrlExternally(link) + } + } + } + } + + footer: Row { + layoutDirection: Qt.RightToLeft + spacing: Theme.space.sm + padding: Theme.space.lg + topPadding: 0 + + // choose: one button per action, the first as the primary. + Repeater { + model: root.kind === "choose" && root.request ? root.request.actions : [] + delegate: Button { + required property string modelData + required property int index + text: modelData + highlighted: index === 0 + onClicked: root.answer(() => root.request.choose(index)) + } + } + + // update: install / later / skip this version. + Button { + visible: root.kind === "update" + highlighted: true + text: qsTr("Install update") + onClicked: root.answer(() => root.request.answerUpdate("install")) + } + Button { + visible: root.kind === "update" + flat: true + text: qsTr("Skip this version") + onClicked: root.answer(() => root.request.answerUpdate("skip")) + } + Button { + visible: root.kind === "update" + flat: true + text: qsTr("Later") + onClicked: root.answer(() => root.request.answerUpdate("later")) + } + + // profileSetup: Create submits and, unlike every other kind, may + // fail without answering the request at all (a taken name, a + // server error) -- so this does not go through root.answer(), which + // would mark it answered before the request is actually done; only + // Cancel (below) ends the request from this footer for this kind. + Button { + visible: root.kind === "profileSetup" + highlighted: true + enabled: root.request && root.request.profileNameStatus === "available" && !root.request.profileSubmitting + text: root.request && root.request.profileSubmitting ? qsTr("Creating…") : qsTr("Create") + onClicked: root.request.submitProfileName(profileNameField.text.trim()) + } + Button { + visible: root.kind === "profileSetup" + flat: true + enabled: !(root.request && root.request.profileSubmitting) + text: qsTr("Cancel") + onClicked: root.answer(() => root.request.reject()) + } + + Button { + visible: root.kind !== "choose" && root.kind !== "update" && root.kind !== "profileSetup" && root.kind !== "filePicker" + enabled: root.kind === "blockedMods" ? root.allFound + : root.kind === "untrustedMods" ? trustBox.checked + : root.kind === "text" ? textAnswer.text.trim().length > 0 : true + highlighted: true + text: root.request && root.request.acceptLabel.length > 0 ? root.request.acceptLabel + : root.kind === "message" ? qsTr("OK") : qsTr("Continue") + onClicked: root.answer(() => root.kind === "text" ? root.request.accept(textAnswer.text.trim()) + : root.request.accept()) + } + Button { + visible: root.kind !== "message" && root.kind !== "update" && root.kind !== "profileSetup" && root.kind !== "filePicker" + flat: true + text: root.request && root.request.rejectLabel.length > 0 ? root.request.rejectLabel : qsTr("Cancel") + onClicked: root.answer(() => root.request.reject()) + } + } + } + + // filePicker: a plugin's open/save dialog, shown natively rather than + // inside root's own themed body (see the file-level comment). Plain + // accept()/reject() -- the same generic answer mechanism "text" uses -- + // so QmlUiHost needs no filePicker-specific invokables of its own. + NativeDialogs.FileDialog { + id: nativeFileDialog + readonly property var request: wrapper.host ? wrapper.host.current : null + + fileMode: request && request.filePickerMode === "save" ? NativeDialogs.FileDialog.SaveFile + : NativeDialogs.FileDialog.OpenFile + nameFilters: request && request.filePickerFilter.length > 0 + ? request.filePickerFilter.split(";;") : [qsTr("All files (*)")] + + onAccepted: if (request) request.accept(selectedFile.toString()) + onRejected: if (request) request.reject() + + Connections { + target: wrapper.host + function onCurrentChanged() { + var req = wrapper.host ? wrapper.host.current : null + if (!req || req.kind !== "filePicker") + return + // Set fresh before every open(), imperatively rather than as + // a binding: FileDialog owns selectedFile once shown (the + // user's pick lives there too), so a binding here would only + // ever apply once. + nativeFileDialog.selectedFile = + req.filePickerMode === "save" + ? Format.fileUrl(req.filePickerDefaultPath) : "" + nativeFileDialog.open() + } + } + } +} diff --git a/launcher/qml/Components/VersionTab.qml b/launcher/qml/Components/VersionTab.qml new file mode 100644 index 00000000..8875461b --- /dev/null +++ b/launcher/qml/Components/VersionTab.qml @@ -0,0 +1,336 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import MeshMC.Theme + +/* + * The instance's components: Minecraft, a mod loader, LWJGL, and anything + * else installed alongside them - the widget-free replacement for + * VersionPage. Long operations (installing/updating a component) run + * through PackProfile's own `task`, shown as a thin progress bar rather + * than a blocking dialog. + */ +Item { + id: root + + // InstanceDetails. + property var details: null + readonly property var components: root.details ? root.details.components : null + readonly property var installer: root.details ? root.details.loaderInstaller : null + readonly property bool locked: !root.details || !root.details.contentChangesAllowed + + ColumnLayout { + anchors.fill: parent + spacing: Theme.space.md + + RowLayout { + Layout.fillWidth: true + spacing: Theme.space.sm + + Text { + Layout.fillWidth: true + text: list.count > 0 ? qsTr("%1 components").arg(list.count) : "" + color: Theme.palette.textTertiary + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + } + Button { + enabled: !root.locked && !!root.components && !root.components.busy + text: qsTr("Change Minecraft version") + onClicked: minecraftVersionDialog.open() + } + Button { + enabled: !root.locked && !!root.installer && !root.components.busy + text: qsTr("Install loader") + icon.source: Icons.url("download") + onClicked: { + loaderInstallDialog.preselectUid = "" + loaderInstallDialog.open() + } + } + IconButton { + iconName: "refresh" + tip: qsTr("Reload") + enabled: !root.locked && !!root.components && !root.components.busy + onClicked: root.components.reloadProfile() + } + } + + Rectangle { + Layout.fillWidth: true + visible: !!root.components && root.components.busy + implicitHeight: statusRow.implicitHeight + Theme.space.sm * 2 + radius: Theme.radius.md + color: Theme.palette.surfaceSunken + border.width: 1 + border.color: Theme.palette.border + + ColumnLayout { + id: statusRow + anchors.fill: parent + anchors.margins: Theme.space.sm + spacing: Theme.space.xs + + Text { + Layout.fillWidth: true + text: root.components && root.components.task + ? (root.components.task.status.length > 0 ? root.components.task.status + : (root.components.task.title.length > 0 ? root.components.task.title : qsTr("Updating…"))) + : qsTr("Updating…") + elide: Text.ElideRight + color: Theme.palette.textSecondary + font.family: Theme.font.family + font.pixelSize: Theme.type.caption.pixelSize + } + LaunchProgressBar { + Layout.fillWidth: true + progress: root.components && root.components.task ? root.components.task.progress : -1 + } + } + } + + Rectangle { + id: errorBanner + Layout.fillWidth: true + property bool dismissed: false + visible: !dismissed && !!root.components && root.components.lastError.length > 0 + implicitHeight: Theme.control.heightLg + radius: Theme.radius.md + color: Theme.palette.dangerSubtle + border.width: 1 + border.color: Theme.palette.danger + + Connections { + target: root.components + function onLastErrorChanged() { errorBanner.dismissed = false } + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: Theme.space.md + anchors.rightMargin: Theme.space.xs + spacing: Theme.space.sm + + MeshIcon { iconName: "alert-triangle"; size: Theme.icon.sm; color: Theme.palette.danger } + Text { + Layout.fillWidth: true + text: root.components ? root.components.lastError : "" + elide: Text.ElideRight + color: Theme.palette.textPrimary + font.family: Theme.font.family + font.pixelSize: Theme.type.body.pixelSize + } + IconButton { iconName: "x"; tip: qsTr("Dismiss"); onClicked: errorBanner.dismissed = true } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + radius: Theme.radius.lg + color: Theme.palette.surfaceSunken + border.width: 1 + border.color: Theme.palette.border + + ListView { + id: list + anchors.fill: parent + anchors.margins: Theme.space.sm + clip: true + spacing: Theme.space.xs + boundsBehavior: Flickable.StopAtBounds + model: root.components + ScrollBar.vertical: ScrollBar {} + + delegate: componentDelegate + } + + EmptyState { + anchors.centerIn: parent + visible: list.count === 0 + title: qsTr("No components") + body: qsTr("This instance has nothing installed yet.") + MeshIcon { iconName: "package"; size: 40; color: Theme.palette.textTertiary } + } + } + } + + Component { + id: componentDelegate + + Rectangle { + id: row + required property int index + required property string name + required property string version + required property string uid + required property string problemSeverity + required property bool isCustom + required property bool isEnabled + required property bool canDisable + required property bool isRemovable + required property bool isMoveable + required property bool isCustomizable + required property bool isRevertible + required property bool hasVersionList + + readonly property bool changeable: uid === "net.minecraft" || root.loaderUids.indexOf(uid) >= 0 + + width: list.width + height: 56 + radius: Theme.radius.md + color: hover.hovered ? Theme.palette.surfaceRaised : "transparent" + Behavior on color { ColorAnimation { duration: Theme.motion.fast } } + + HoverHandler { id: hover } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: Theme.space.sm + anchors.rightMargin: Theme.space.sm + spacing: Theme.space.sm + + MeshIcon { + visible: row.problemSeverity === "warning" || row.problemSeverity === "error" + iconName: "alert-triangle" + size: Theme.icon.sm + color: row.problemSeverity === "error" ? Theme.palette.danger : Theme.palette.warning + } + + Column { + Layout.fillWidth: true + Layout.minimumWidth: 0 + spacing: Theme.space.xxs + + Text { + width: parent.width + text: row.name + elide: Text.ElideRight + color: row.isEnabled ? Theme.palette.textPrimary : Theme.palette.textTertiary + font.family: Theme.font.family + font.pixelSize: Theme.type.bodyStrong.pixelSize + font.weight: Theme.type.bodyStrong.weight + } + Row { + spacing: Theme.space.xs + Tag { text: row.version } + StatusBadge { visible: row.isCustom; text: qsTr("Custom"); tone: "warning" } + StatusBadge { visible: !row.isEnabled; text: qsTr("Off") } + } + } + + RowLayout { + visible: hover.hovered + spacing: Theme.space.xxs + + IconButton { + visible: row.isMoveable + enabled: !root.locked && row.index > 0 + iconName: "arrow-up" + tip: qsTr("Move up") + onClicked: root.components.moveComponentUp(row.index) + } + IconButton { + visible: row.isMoveable + enabled: !root.locked && row.index < list.count - 1 + iconName: "arrow-down" + tip: qsTr("Move down") + onClicked: root.components.moveComponentDown(row.index) + } + IconButton { + visible: row.changeable && row.hasVersionList + enabled: !root.locked + iconName: "download" + tip: qsTr("Change version") + onClicked: root.openChangeVersion(row.uid) + } + IconButton { + visible: row.isCustomizable && !row.isCustom + enabled: !root.locked + iconName: "edit" + tip: qsTr("Customize") + onClicked: root.components.customizeComponent(row.index) + } + IconButton { + visible: row.isRevertible + enabled: !root.locked + iconName: "refresh" + tip: qsTr("Revert to the original") + onClicked: { + revertConfirm.row = row.index + revertConfirm.text = qsTr("Revert “%1” to its original version? Your own changes to it are lost.").arg(row.name) + revertConfirm.open() + } + } + IconButton { + visible: row.isRemovable + enabled: !root.locked + iconName: "trash" + tip: qsTr("Remove") + onClicked: { + removeConfirm.row = row.index + removeConfirm.text = qsTr("Remove “%1” from this instance?").arg(row.name) + removeConfirm.open() + } + } + } + + Switch { + visible: row.canDisable + enabled: !root.locked + checked: row.isEnabled + Accessible.name: qsTr("Enable %1").arg(row.name) + onToggled: { + root.components.setComponentEnabled(row.uid, checked) + checked = Qt.binding(() => row.isEnabled) + } + } + } + } + } + + // Every loader uid this launcher knows how to install - used to decide + // whether a row's uid can open the loader dialog (vs. the Minecraft + // version dialog, vs. no "change version" action at all for a + // component this tab does not have a picker for, e.g. LWJGL). + readonly property var loaderUids: root.installer ? root.installer.loaders.map(function (l) { return l.uid }) : [] + + function openChangeVersion(uid) { + if (uid === "net.minecraft") { + minecraftVersionDialog.open() + } else if (root.installer) { + loaderInstallDialog.preselectUid = uid + loaderInstallDialog.open() + } + } + + MinecraftVersionDialog { + id: minecraftVersionDialog + details: root.details + } + + LoaderInstallDialog { + id: loaderInstallDialog + installer: root.installer + } + + ConfirmDialog { + id: removeConfirm + property int row: -1 + title: qsTr("Remove component") + confirmText: qsTr("Remove") + onConfirmed: if (root.components) root.components.removeComponent(row) + } + + ConfirmDialog { + id: revertConfirm + property int row: -1 + title: qsTr("Revert component") + confirmText: qsTr("Revert") + onConfirmed: if (root.components) root.components.revertComponent(row) + } +} diff --git a/launcher/qml/Components/WorldsTab.qml b/launcher/qml/Components/WorldsTab.qml new file mode 100644 index 00000000..6231b87c --- /dev/null +++ b/launcher/qml/Components/WorldsTab.qml @@ -0,0 +1,293 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import MeshMC.Theme + +/* + * The instance's single-player worlds, most useful facts first: name, + * game mode, when it was last played. Deleting one asks first -- a world + * is hours of someone's play and there is no undo. + */ +Item { + id: root + + // InstanceDetails: worlds (WorldList: folder, seed, name, gameMode, + // lastPlayed, iconFile, dayCount), worldsDir, deleteWorld, renameWorld, + // copyWorld, resetWorldIcon. + property var details: null + readonly property bool unlocked: !!details && details.contentChangesAllowed + readonly property int count: list.count + signal openFolderRequested(string path) + // Asks the instance page to switch to the Data packs tab pointed at + // world `row` - see InstancePage.qml. + signal dataPacksRequested(int row) + + // The TaskWatcher of a copy in progress, if any - copyWorld() runs off + // the GUI thread (a world can run into gigabytes), see InstanceDetails. + property var watcher: null + readonly property bool busy: !!root.watcher && root.watcher.running + + // A different instance's details: whatever this tab was doing belonged + // to the previous one. + onDetailsChanged: root.watcher = null + + ColumnLayout { + anchors.fill: parent + spacing: Theme.space.md + + RowLayout { + Layout.fillWidth: true + Text { + Layout.fillWidth: true + text: list.count > 0 ? qsTr("%1 worlds").arg(list.count) : "" + color: Theme.palette.textTertiary + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + } + Button { + text: qsTr("Open folder") + icon.source: Icons.url("folder") + onClicked: root.openFolderRequested(root.details ? root.details.worldsDir : "") + } + } + + RowLayout { + Layout.fillWidth: true + visible: root.busy || (!!root.watcher && root.watcher.failed) + spacing: Theme.space.md + + Text { + Layout.fillWidth: true + text: root.watcher + ? (root.watcher.failed ? (root.watcher.error || qsTr("Copy failed.")) + : (root.watcher.status || qsTr("Copying world…"))) + : "" + color: root.watcher && root.watcher.failed ? Theme.palette.danger : Theme.palette.textSecondary + elide: Text.ElideRight + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + } + LaunchProgressBar { + Layout.preferredWidth: 160 + visible: root.busy + progress: root.watcher ? root.watcher.progress : -1 + } + } + + ListView { + id: list + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + spacing: Theme.space.sm + boundsBehavior: Flickable.StopAtBounds + model: root.details ? root.details.worlds : null + ScrollBar.vertical: ScrollBar {} + + delegate: Item { + id: cell + required property int index + required property string name + required property string folder + required property var gameMode + required property var lastPlayed + required property var iconFile + required property var dayCount + + width: list.width - Theme.space.md + height: 76 + + HoverHandler { id: hover } + + // A faint duplicate a few pixels below the card reads as a + // soft drop shadow without a real blur (none of the effect + // modules are available at this Qt floor). + Rectangle { + x: 0; y: 3 + width: parent.width + height: parent.height + radius: Theme.radius.lg + color: Theme.palette.scrim + opacity: hover.hovered ? 0.16 : 0.08 + Behavior on opacity { NumberAnimation { duration: Theme.motion.fast } } + } + + Rectangle { + id: card + width: parent.width + height: parent.height + y: hover.hovered ? -1 : 0 + radius: Theme.radius.lg + color: hover.hovered ? Theme.palette.surfaceRaised : Theme.palette.surface + border.width: 1 + border.color: hover.hovered ? Theme.palette.borderStrong : Theme.palette.border + + Behavior on y { NumberAnimation { duration: Theme.motion.normal; easing.type: Theme.motion.easing } } + Behavior on color { ColorAnimation { duration: Theme.motion.fast } } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: Theme.space.md + anchors.rightMargin: Theme.space.md + spacing: Theme.space.md + + Rectangle { + Layout.preferredWidth: 52 + Layout.preferredHeight: 52 + radius: Theme.radius.md + color: Theme.palette.surfaceSunken + Image { + id: worldIcon + anchors.fill: parent + anchors.margins: 2 + source: Format.fileUrl(cell.iconFile) + sourceSize: Qt.size(96, 96) + smooth: false + visible: status === Image.Ready + } + MeshIcon { + anchors.centerIn: parent + visible: !worldIcon.visible + iconName: "globe" + color: Theme.palette.textTertiary + } + } + + Column { + Layout.fillWidth: true + Layout.minimumWidth: 0 + spacing: Theme.space.xs + Text { + width: parent.width + text: cell.name.length > 0 ? cell.name : cell.folder + elide: Text.ElideRight + color: Theme.palette.textPrimary + font.family: Theme.font.family + font.pixelSize: Theme.type.bodyStrong.pixelSize + font.weight: Theme.type.bodyStrong.weight + } + Row { + spacing: Theme.space.xs + Tag { + visible: text.length > 0 + text: String(cell.gameMode || "") + } + Tag { + visible: cell.dayCount !== undefined && cell.dayCount !== null + text: qsTr("Day %1").arg(cell.dayCount) + } + Tag { + iconName: "clock" + text: Format.lastPlayed(cell.lastPlayed ? Number(cell.lastPlayed) : 0) + } + } + } + + IconButton { + visible: hover.hovered && root.unlocked + iconName: "package" + tip: qsTr("Data packs") + onClicked: root.dataPacksRequested(cell.index) + } + + IconButton { + visible: hover.hovered && root.unlocked + enabled: !root.busy + iconName: "more" + tip: qsTr("More") + onClicked: rowMenu.popup() + + Menu { + id: rowMenu + MenuItem { + text: qsTr("Rename…") + icon.source: Icons.url("edit") + onTriggered: { + renamePrompt.row = cell.index + renamePrompt.value = cell.name.length > 0 ? cell.name : cell.folder + renamePrompt.open() + } + } + MenuItem { + text: qsTr("Copy…") + icon.source: Icons.url("copy") + onTriggered: { + copyPrompt.row = cell.index + copyPrompt.value = qsTr("%1 (copy)").arg(cell.name.length > 0 ? cell.name : cell.folder) + copyPrompt.open() + } + } + MenuItem { + text: qsTr("Reset icon") + icon.source: Icons.url("image") + enabled: !!cell.iconFile + onTriggered: if (root.details) root.details.resetWorldIcon(cell.index) + } + MenuSeparator {} + MenuItem { + text: qsTr("Delete…") + icon.source: Icons.url("trash") + onTriggered: { + confirm.row = cell.index + confirm.text = qsTr("Delete the world “%1”? It cannot be recovered from the launcher.").arg(cell.name.length > 0 ? cell.name : cell.folder) + confirm.open() + } + } + } + } + } + } + } + } + } + + ConfirmDialog { + id: confirm + property int row: -1 + title: qsTr("Delete world") + confirmText: qsTr("Delete world") + onConfirmed: if (root.details) root.details.deleteWorld(row) + } + + PromptDialog { + id: renamePrompt + property int row: -1 + title: qsTr("Rename world") + confirmText: qsTr("Rename") + onSubmitted: (text) => { + if (!root.details || root.details.renameWorld(row, text)) + close() + else + error = qsTr("That name cannot be used.") + } + } + + PromptDialog { + id: copyPrompt + property int row: -1 + title: qsTr("Copy world") + confirmText: qsTr("Copy") + onSubmitted: (text) => { + var watcher = root.details ? root.details.copyWorld(row, text) : null + if (watcher) { + root.watcher = watcher + close() + } else { + error = qsTr("That name cannot be used.") + } + } + } + + EmptyState { + anchors.centerIn: parent + upperThird: true + visible: list.count === 0 + title: qsTr("No worlds yet") + body: qsTr("Worlds you create in single player show up here.") + MeshIcon { iconName: "globe"; size: 40; color: Theme.palette.textTertiary } + } +} diff --git a/launcher/qml/Components/art/ambient/blocks_mask.png b/launcher/qml/Components/art/ambient/blocks_mask.png new file mode 100644 index 00000000..1fab059d Binary files /dev/null and b/launcher/qml/Components/art/ambient/blocks_mask.png differ diff --git a/launcher/qml/Components/art/blocks/cobblestone.png b/launcher/qml/Components/art/blocks/cobblestone.png new file mode 100644 index 00000000..4209a027 Binary files /dev/null and b/launcher/qml/Components/art/blocks/cobblestone.png differ diff --git a/launcher/qml/Components/art/blocks/deepslate.png b/launcher/qml/Components/art/blocks/deepslate.png new file mode 100644 index 00000000..4000d70b Binary files /dev/null and b/launcher/qml/Components/art/blocks/deepslate.png differ diff --git a/launcher/qml/Components/art/blocks/dirt.png b/launcher/qml/Components/art/blocks/dirt.png new file mode 100644 index 00000000..fd7344aa Binary files /dev/null and b/launcher/qml/Components/art/blocks/dirt.png differ diff --git a/launcher/qml/Components/art/blocks/grass_side.png b/launcher/qml/Components/art/blocks/grass_side.png new file mode 100644 index 00000000..250e9029 Binary files /dev/null and b/launcher/qml/Components/art/blocks/grass_side.png differ diff --git a/launcher/qml/Components/art/blocks/grass_top.png b/launcher/qml/Components/art/blocks/grass_top.png new file mode 100644 index 00000000..61364a57 Binary files /dev/null and b/launcher/qml/Components/art/blocks/grass_top.png differ diff --git a/launcher/qml/Components/art/blocks/gravel.png b/launcher/qml/Components/art/blocks/gravel.png new file mode 100644 index 00000000..b7203fa3 Binary files /dev/null and b/launcher/qml/Components/art/blocks/gravel.png differ diff --git a/launcher/qml/Components/art/blocks/oak_planks.png b/launcher/qml/Components/art/blocks/oak_planks.png new file mode 100644 index 00000000..fb73fa79 Binary files /dev/null and b/launcher/qml/Components/art/blocks/oak_planks.png differ diff --git a/launcher/qml/Components/art/blocks/sand.png b/launcher/qml/Components/art/blocks/sand.png new file mode 100644 index 00000000..fa4a4ac6 Binary files /dev/null and b/launcher/qml/Components/art/blocks/sand.png differ diff --git a/launcher/qml/Components/art/blocks/stone.png b/launcher/qml/Components/art/blocks/stone.png new file mode 100644 index 00000000..661b9871 Binary files /dev/null and b/launcher/qml/Components/art/blocks/stone.png differ diff --git a/launcher/qml/Components/art/empty/no_instances.png b/launcher/qml/Components/art/empty/no_instances.png new file mode 100644 index 00000000..84ebab36 Binary files /dev/null and b/launcher/qml/Components/art/empty/no_instances.png differ diff --git a/launcher/qml/Components/art/empty/no_worlds.png b/launcher/qml/Components/art/empty/no_worlds.png new file mode 100644 index 00000000..c2691141 Binary files /dev/null and b/launcher/qml/Components/art/empty/no_worlds.png differ diff --git a/launcher/qml/Components/art/empty/not_found.png b/launcher/qml/Components/art/empty/not_found.png new file mode 100644 index 00000000..01997aef Binary files /dev/null and b/launcher/qml/Components/art/empty/not_found.png differ diff --git a/launcher/qml/Components/art/generate.py b/launcher/qml/Components/art/generate.py new file mode 100644 index 00000000..79f6eee1 --- /dev/null +++ b/launcher/qml/Components/art/generate.py @@ -0,0 +1,375 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 Project Tick +# SPDX-FileContributor: Project Tick +# SPDX-License-Identifier: Apache-2.0 +""" +Generates every original pixel-art asset MeshMC's QML ships as PNGs: +block textures, the chrome-only-screen ambient tile, per-item landscape +fallbacks (24 scenes at three shapes, drawn by scenes.py), the Home page +hero banner and a handful of empty-state illustrations. + +Original artwork only -- no Mojang/Minecraft assets are read, copied or +referenced; palettes and shapes below are this script's own, only evoking +the same blocky, low-resolution style MeshMC's design direction calls for +(design-plan.md "Imagery and copy rules"). + +Deterministic: every image is built from a fixed random.Random(seed), so +re-running this script reproduces byte-identical PNGs. That is also why the +output is checked into git rather than generated at build or run time -- see +each QML consumer's own comment for why no QML code ever runs this at +runtime. + +Usage: python3 generate.py (from anywhere; paths below are relative to this +file, not the working directory). +""" + +import os +import random +from PIL import Image + +import scenes + +ROOT = os.path.dirname(os.path.abspath(__file__)) + + +def save(img: Image.Image, *parts: str) -> None: + path = os.path.join(ROOT, *parts) + os.makedirs(os.path.dirname(path), exist_ok=True) + img.save(path, optimize=True) + print("wrote", os.path.relpath(path, ROOT), img.size) + + +def upscale(img: Image.Image, factor: int) -> Image.Image: + """Nearest-neighbour upscale -- bakes the blocky look into the file + itself, on top of whatever further nearest-neighbour scaling QML does + at display time (smooth: false).""" + return img.resize((img.width * factor, img.height * factor), Image.NEAREST) + + +def jitter(rng: random.Random, color, spread=10): + return tuple(max(0, min(255, c + rng.randint(-spread, spread))) for c in color) + + +# --------------------------------------------------------------------------- +# 1. 16x16 block textures +# --------------------------------------------------------------------------- + +def speckle(size, base, dark, light, seed, dark_p=0.18, light_p=0.12, spread=6): + rng = random.Random(seed) + img = Image.new("RGB", (size, size)) + for y in range(size): + for x in range(size): + r = rng.random() + if r < dark_p: + c = jitter(rng, dark, spread) + elif r < dark_p + light_p: + c = jitter(rng, light, spread) + else: + c = jitter(rng, base, spread) + img.putpixel((x, y), c) + return img + + +def block_grass_top(seed): + return speckle(16, (0x6A, 0xA3, 0x49), (0x54, 0x86, 0x39), (0x86, 0xC1, 0x5B), seed, + dark_p=0.16, light_p=0.14, spread=8) + + +def block_grass_side(seed): + rng = random.Random(seed) + img = Image.new("RGB", (16, 16)) + dirt_base, dirt_dark, dirt_light = (0x8B, 0x5A, 0x2B), (0x6E, 0x46, 0x20), (0x9C, 0x6A, 0x38) + grass_base, grass_dark = (0x6A, 0xA3, 0x49), (0x54, 0x86, 0x39) + # A jagged 3-4px grass fringe over dirt, not a straight cut. + edge = [4 + rng.randint(-1, 1) for _ in range(16)] + for x in range(16): + for y in range(16): + if y < edge[x] - 1: + c = jitter(rng, grass_base, 8) if rng.random() > 0.2 else jitter(rng, grass_dark, 8) + elif y < edge[x]: + c = jitter(rng, grass_dark, 6) + else: + r = rng.random() + c = jitter(rng, dirt_dark if r < 0.18 else dirt_light if r < 0.30 else dirt_base, 6) + img.putpixel((x, y), c) + return img + + +def block_dirt(seed): + return speckle(16, (0x8B, 0x5A, 0x2B), (0x6E, 0x46, 0x20), (0x9C, 0x6A, 0x38), seed, + dark_p=0.20, light_p=0.14, spread=8) + + +def block_stone(seed): + return speckle(16, (0x8A, 0x8A, 0x8E), (0x74, 0x74, 0x78), (0x9C, 0x9C, 0xA0), seed, + dark_p=0.16, light_p=0.12, spread=6) + + +def block_cobblestone(seed): + rng = random.Random(seed) + img = Image.new("RGB", (16, 16), (0x4B, 0x4B, 0x4E)) + base, dark, light = (0x8A, 0x8A, 0x8E), (0x6E, 0x6E, 0x72), (0x9E, 0x9E, 0xA2) + # A handful of rounded cobble blobs with dark mortar showing between them. + centers = [(rng.randint(1, 14), rng.randint(1, 14), rng.randint(2, 3)) for _ in range(9)] + for x in range(16): + for y in range(16): + hit = None + for cx, cy, r in centers: + if (x - cx) ** 2 + (y - cy) ** 2 <= r * r: + hit = (cx, cy, r) + if hit: + cx, cy, r = hit + shade = base if (x + y + cx) % 3 else (dark if (x + y) % 2 else light) + img.putpixel((x, y), jitter(rng, shade, 5)) + return img + + +def block_oak_planks(seed): + rng = random.Random(seed) + img = Image.new("RGB", (16, 16)) + base, grain, edge = (0xB9, 0x86, 0x50), (0xA5, 0x74, 0x42), (0x8F, 0x62, 0x38) + for y in range(16): + row_edge = (y % 4 == 0) + for x in range(16): + if row_edge: + c = edge + elif rng.random() < 0.12: + c = grain + else: + c = base + img.putpixel((x, y), jitter(rng, c, 5)) + return img + + +def block_deepslate(seed): + return speckle(16, (0x3F, 0x41, 0x47), (0x2E, 0x30, 0x35), (0x50, 0x53, 0x5A), seed, + dark_p=0.20, light_p=0.12, spread=6) + + +def block_sand(seed): + return speckle(16, (0xDC, 0xC9, 0x8B), (0xC8, 0xB3, 0x74), (0xE8, 0xD9, 0xA0), seed, + dark_p=0.16, light_p=0.14, spread=6) + + +def block_gravel(seed): + return speckle(16, (0x8D, 0x8A, 0x87), (0x6A, 0x67, 0x64), (0xAB, 0xA6, 0xA0), seed, + dark_p=0.26, light_p=0.22, spread=14) + + +BLOCKS = { + "grass_top": block_grass_top, + "grass_side": block_grass_side, + "dirt": block_dirt, + "stone": block_stone, + "cobblestone": block_cobblestone, + "oak_planks": block_oak_planks, + "deepslate": block_deepslate, + "sand": block_sand, + "gravel": block_gravel, +} + + +def generate_blocks(): + for i, (name, fn) in enumerate(BLOCKS.items()): + save(fn(seed=1000 + i), "blocks", f"{name}.png") + + +# --------------------------------------------------------------------------- +# 1b. Ambient wash mask -- a 2x2 dirt/deepslate checkerboard, converted to a +# luminance alpha mask so QML's IconImage (the same alpha-channel +# recolouring MeshIcon.qml already uses for every SVG icon, no +# ShaderEffect/MultiEffect involved) can tint it to any palette colour +# while its own speckle/grain still reads as texture, not a flat square. +# --------------------------------------------------------------------------- + +def _luminance_alpha(img, curve=lambda l: l): + """RGB image -> RGBA mask: alpha = curve(luminance), RGB = white (the + RGB channel is irrelevant, since IconImage overwrites it with its own + `color` and only reads this image's alpha).""" + w, h = img.size + src = img.load() + out = Image.new("RGBA", (w, h)) + dst = out.load() + for y in range(h): + for x in range(w): + r, g, b = src[x, y] + lum = (r * 0.299 + g * 0.587 + b * 0.114) / 255 + a = max(0, min(255, round(255 * curve(lum)))) + dst[x, y] = (255, 255, 255, a) + return out + + +def ambient_tile(): + dirt = block_dirt(seed=5000) + deepslate = block_deepslate(seed=5001) + tile = Image.new("RGB", (32, 32)) + tile.paste(dirt, (0, 0)) + tile.paste(deepslate, (16, 0)) + tile.paste(deepslate, (0, 16)) + tile.paste(dirt, (16, 16)) + return _luminance_alpha(tile, curve=lambda l: 0.4 + l * 0.6) + + +def generate_ambient(): + save(ambient_tile(), "ambient", "blocks_mask.png") + + +# --------------------------------------------------------------------------- +# 2. Landscape fallbacks -- 24 scenes x 3 shapes (scenes.py), nearest-upscaled +# 2x. See scenes.py for why one scene is drawn three times. +# --------------------------------------------------------------------------- + +def generate_landscapes(): + for i in range(scenes.SCENE_COUNT): + for shape in scenes.SHAPES: + save(upscale(scenes.render(i, shape), 2), "landscapes", shape, f"{i:02d}.png") + + +# Helpers the hero banner below still uses. + +def draw_sky(img, w, h, bands): + for i, (pos, color) in enumerate(bands): + y0 = int(pos * h) + y1 = int(bands[i + 1][0] * h) if i + 1 < len(bands) else h + for y in range(y0, y1): + for x in range(w): + img.putpixel((x, y), color) + + +def draw_hillband(img, w, h, base_y, amplitude, color, rng, step=3): + heights = [] + cur = base_y + for x in range(0, w + step, step): + cur += rng.randint(-amplitude, amplitude) + cur = max(base_y - amplitude * 2, min(h - 2, cur)) + heights.extend([cur] * step) + for x in range(w): + top = heights[x] if x < len(heights) else heights[-1] + for y in range(int(top), h): + img.putpixel((x, y), color) + + +# --------------------------------------------------------------------------- +# 3. Home hero banner -- logical 96x18 grid, nearest-upscaled 5x to 480x90. +# --------------------------------------------------------------------------- + +def hero_banner(seed=3000): + w, h = 96, 18 + img = Image.new("RGB", (w, h)) + rng = random.Random(seed) + sky = [(0.00, (0x35, 0x40, 0x64)), (0.35, (0x5C, 0x5C, 0x86)), (0.60, (0x9A, 0x76, 0x86)), (0.82, (0xD4, 0x9A, 0x6C))] + draw_sky(img, w, h, sky) + + # A quiet moon/sun disc, off-centre. + ax, ay, r = int(w * 0.74), 4, 2 + for dx in range(-r, r + 1): + for dy in range(-r, r + 1): + if dx * dx + dy * dy <= r * r + 1: + xx, yy = ax + dx, ay + dy + if 0 <= xx < w and 0 <= yy < h: + img.putpixel((xx, yy), (0xF0, 0xD8, 0xA6)) + + horizon = int(h * 0.60) + draw_hillband(img, w, h, horizon, 1, (0x2E, 0x33, 0x38), rng, step=3) + draw_hillband(img, w, h, horizon + 2, 1, (0x20, 0x24, 0x28), rng, step=5) + for y in range(horizon + 4, h): + for x in range(w): + img.putpixel((x, y), (0x16, 0x19, 0x1C)) + + # A distant, blocky silhouette skyline -- towers, not trees, so the + # banner reads as "a place with structures", not another landscape tile. + x = 4 + while x < w - 4: + bw = rng.randint(2, 4) + bh = rng.randint(2, 5) + by = horizon + 3 - bh + for xx in range(x, min(w, x + bw)): + for yy in range(by, horizon + 3): + if 0 <= yy < h: + img.putpixel((xx, yy), (0x1C, 0x20, 0x24)) + x += bw + rng.randint(2, 5) + + return upscale(img, 5) + + +def generate_hero(): + save(hero_banner(), "hero", "home_hero.png") + + +# --------------------------------------------------------------------------- +# 4. Empty-state illustrations -- logical 24x24 grid, nearest-upscaled 2x. +# --------------------------------------------------------------------------- + +def empty_no_instances(seed=4000): + # A single lidless crafting-table-like block with a "+" carved in, on a + # short ground strip -- reads as "place a new one here". + w = h = 24 + img = Image.new("RGBA", (w, h), (0, 0, 0, 0)) + rng = random.Random(seed) + base, dark, light = (0x9C, 0x6A, 0x38), (0x7A, 0x50, 0x28), (0xB8, 0x86, 0x50) + for y in range(14, 20): + for x in range(4, 20): + img.putpixel((x, y), jitter(rng, base, 6) + (255,)) + for x in range(4, 20): + img.putpixel((x, 14), dark + (255,)) + # ground shadow + for x in range(3, 21): + img.putpixel((x, 20), (0, 0, 0, 60)) + # a plus mark + for d in range(-3, 4): + img.putpixel((12 + d, 9), light + (255,)) + img.putpixel((12, 9 + d), light + (255,)) + return upscale(img, 2) + + +def empty_no_worlds(seed=4001): + # A small pixel globe/compass -- a blank map with a dashed border. + w = h = 24 + img = Image.new("RGBA", (w, h), (0, 0, 0, 0)) + rng = random.Random(seed) + paper, edge = (0xE7, 0xD9, 0xAE), (0xB8, 0x9F, 0x6C) + for y in range(4, 20): + for x in range(4, 20): + img.putpixel((x, y), jitter(rng, paper, 5) + (255,)) + for x in range(4, 20): + img.putpixel((x, 4), edge + (255,)) + img.putpixel((x, 19), edge + (255,)) + for y in range(4, 20): + img.putpixel((4, y), edge + (255,)) + img.putpixel((19, y), edge + (255,)) + # a dashed compass needle + for i, (x, y) in enumerate([(11, 9), (12, 10), (12, 11), (11, 12), (10, 13), (11, 14)]): + img.putpixel((x, y), (0xB0, 0x4A, 0x3A, 255) if i % 2 else (0x3A, 0x5C, 0xB0, 255)) + return upscale(img, 2) + + +def empty_not_found(seed=4002): + # A cracked stone block with a "?" notch -- nothing matched. + w = h = 24 + img = Image.new("RGBA", (w, h), (0, 0, 0, 0)) + rng = random.Random(seed) + base, dark = (0x8A, 0x8A, 0x8E), (0x66, 0x66, 0x6A) + for y in range(4, 20): + for x in range(4, 20): + img.putpixel((x, y), jitter(rng, base, 5) + (255,)) + # a jagged crack + cx = 12 + for y in range(4, 20): + cx += rng.choice([-1, 0, 0, 1]) + cx = max(6, min(17, cx)) + img.putpixel((cx, y), dark + (255,)) + return upscale(img, 2) + + +def generate_empty_states(): + save(empty_no_instances(), "empty", "no_instances.png") + save(empty_no_worlds(), "empty", "no_worlds.png") + save(empty_not_found(), "empty", "not_found.png") + + +if __name__ == "__main__": + generate_blocks() + generate_ambient() + generate_landscapes() + generate_hero() + generate_empty_states() diff --git a/launcher/qml/Components/art/hero/home_hero.png b/launcher/qml/Components/art/hero/home_hero.png new file mode 100644 index 00000000..58d0fa08 Binary files /dev/null and b/launcher/qml/Components/art/hero/home_hero.png differ diff --git a/launcher/qml/Components/art/landscapes/band/00.png b/launcher/qml/Components/art/landscapes/band/00.png new file mode 100644 index 00000000..5f464c4c Binary files /dev/null and b/launcher/qml/Components/art/landscapes/band/00.png differ diff --git a/launcher/qml/Components/art/landscapes/band/01.png b/launcher/qml/Components/art/landscapes/band/01.png new file mode 100644 index 00000000..40edb0ff Binary files /dev/null and b/launcher/qml/Components/art/landscapes/band/01.png differ diff --git a/launcher/qml/Components/art/landscapes/band/02.png b/launcher/qml/Components/art/landscapes/band/02.png new file mode 100644 index 00000000..d631af50 Binary files /dev/null and b/launcher/qml/Components/art/landscapes/band/02.png differ diff --git a/launcher/qml/Components/art/landscapes/band/03.png b/launcher/qml/Components/art/landscapes/band/03.png new file mode 100644 index 00000000..750521e6 Binary files /dev/null and b/launcher/qml/Components/art/landscapes/band/03.png differ diff --git a/launcher/qml/Components/art/landscapes/band/04.png b/launcher/qml/Components/art/landscapes/band/04.png new file mode 100644 index 00000000..22d4b7f8 Binary files /dev/null and b/launcher/qml/Components/art/landscapes/band/04.png differ diff --git a/launcher/qml/Components/art/landscapes/band/05.png b/launcher/qml/Components/art/landscapes/band/05.png new file mode 100644 index 00000000..bd3cd2fc Binary files /dev/null and b/launcher/qml/Components/art/landscapes/band/05.png differ diff --git a/launcher/qml/Components/art/landscapes/band/06.png b/launcher/qml/Components/art/landscapes/band/06.png new file mode 100644 index 00000000..69f6fcf0 Binary files /dev/null and b/launcher/qml/Components/art/landscapes/band/06.png differ diff --git a/launcher/qml/Components/art/landscapes/band/07.png b/launcher/qml/Components/art/landscapes/band/07.png new file mode 100644 index 00000000..fd30f795 Binary files /dev/null and b/launcher/qml/Components/art/landscapes/band/07.png differ diff --git a/launcher/qml/Components/art/landscapes/band/08.png b/launcher/qml/Components/art/landscapes/band/08.png new file mode 100644 index 00000000..2d6148bf Binary files /dev/null and b/launcher/qml/Components/art/landscapes/band/08.png differ diff --git a/launcher/qml/Components/art/landscapes/band/09.png b/launcher/qml/Components/art/landscapes/band/09.png new file mode 100644 index 00000000..2be91898 Binary files /dev/null and b/launcher/qml/Components/art/landscapes/band/09.png differ diff --git a/launcher/qml/Components/art/landscapes/band/10.png b/launcher/qml/Components/art/landscapes/band/10.png new file mode 100644 index 00000000..283e89a6 Binary files /dev/null and b/launcher/qml/Components/art/landscapes/band/10.png differ diff --git a/launcher/qml/Components/art/landscapes/band/11.png b/launcher/qml/Components/art/landscapes/band/11.png new file mode 100644 index 00000000..adbd7b23 Binary files /dev/null and b/launcher/qml/Components/art/landscapes/band/11.png differ diff --git a/launcher/qml/Components/art/landscapes/band/12.png b/launcher/qml/Components/art/landscapes/band/12.png new file mode 100644 index 00000000..279b0011 Binary files /dev/null and b/launcher/qml/Components/art/landscapes/band/12.png differ diff --git a/launcher/qml/Components/art/landscapes/band/13.png b/launcher/qml/Components/art/landscapes/band/13.png new file mode 100644 index 00000000..f3d10d18 Binary files /dev/null and b/launcher/qml/Components/art/landscapes/band/13.png differ diff --git a/launcher/qml/Components/art/landscapes/band/14.png b/launcher/qml/Components/art/landscapes/band/14.png new file mode 100644 index 00000000..74ca59d9 Binary files /dev/null and b/launcher/qml/Components/art/landscapes/band/14.png differ diff --git a/launcher/qml/Components/art/landscapes/band/15.png b/launcher/qml/Components/art/landscapes/band/15.png new file mode 100644 index 00000000..429bcad0 Binary files /dev/null and b/launcher/qml/Components/art/landscapes/band/15.png differ diff --git a/launcher/qml/Components/art/landscapes/band/16.png b/launcher/qml/Components/art/landscapes/band/16.png new file mode 100644 index 00000000..2ba6a7d6 Binary files /dev/null and b/launcher/qml/Components/art/landscapes/band/16.png differ diff --git a/launcher/qml/Components/art/landscapes/band/17.png b/launcher/qml/Components/art/landscapes/band/17.png new file mode 100644 index 00000000..0526b248 Binary files /dev/null and b/launcher/qml/Components/art/landscapes/band/17.png differ diff --git a/launcher/qml/Components/art/landscapes/band/18.png b/launcher/qml/Components/art/landscapes/band/18.png new file mode 100644 index 00000000..c5fc07f5 Binary files /dev/null and b/launcher/qml/Components/art/landscapes/band/18.png differ diff --git a/launcher/qml/Components/art/landscapes/band/19.png b/launcher/qml/Components/art/landscapes/band/19.png new file mode 100644 index 00000000..4306daf5 Binary files /dev/null and b/launcher/qml/Components/art/landscapes/band/19.png differ diff --git a/launcher/qml/Components/art/landscapes/band/20.png b/launcher/qml/Components/art/landscapes/band/20.png new file mode 100644 index 00000000..ee846e5a Binary files /dev/null and b/launcher/qml/Components/art/landscapes/band/20.png differ diff --git a/launcher/qml/Components/art/landscapes/band/21.png b/launcher/qml/Components/art/landscapes/band/21.png new file mode 100644 index 00000000..c806c6cf Binary files /dev/null and b/launcher/qml/Components/art/landscapes/band/21.png differ diff --git a/launcher/qml/Components/art/landscapes/band/22.png b/launcher/qml/Components/art/landscapes/band/22.png new file mode 100644 index 00000000..58882563 Binary files /dev/null and b/launcher/qml/Components/art/landscapes/band/22.png differ diff --git a/launcher/qml/Components/art/landscapes/band/23.png b/launcher/qml/Components/art/landscapes/band/23.png new file mode 100644 index 00000000..0693d439 Binary files /dev/null and b/launcher/qml/Components/art/landscapes/band/23.png differ diff --git a/launcher/qml/Components/art/landscapes/card/00.png b/launcher/qml/Components/art/landscapes/card/00.png new file mode 100644 index 00000000..55795fea Binary files /dev/null and b/launcher/qml/Components/art/landscapes/card/00.png differ diff --git a/launcher/qml/Components/art/landscapes/card/01.png b/launcher/qml/Components/art/landscapes/card/01.png new file mode 100644 index 00000000..04c4b462 Binary files /dev/null and b/launcher/qml/Components/art/landscapes/card/01.png differ diff --git a/launcher/qml/Components/art/landscapes/card/02.png b/launcher/qml/Components/art/landscapes/card/02.png new file mode 100644 index 00000000..09c0dc8c Binary files /dev/null and b/launcher/qml/Components/art/landscapes/card/02.png differ diff --git a/launcher/qml/Components/art/landscapes/card/03.png b/launcher/qml/Components/art/landscapes/card/03.png new file mode 100644 index 00000000..a3051bfd Binary files /dev/null and b/launcher/qml/Components/art/landscapes/card/03.png differ diff --git a/launcher/qml/Components/art/landscapes/card/04.png b/launcher/qml/Components/art/landscapes/card/04.png new file mode 100644 index 00000000..14ff79d5 Binary files /dev/null and b/launcher/qml/Components/art/landscapes/card/04.png differ diff --git a/launcher/qml/Components/art/landscapes/card/05.png b/launcher/qml/Components/art/landscapes/card/05.png new file mode 100644 index 00000000..84a1f42a Binary files /dev/null and b/launcher/qml/Components/art/landscapes/card/05.png differ diff --git a/launcher/qml/Components/art/landscapes/card/06.png b/launcher/qml/Components/art/landscapes/card/06.png new file mode 100644 index 00000000..93a24399 Binary files /dev/null and b/launcher/qml/Components/art/landscapes/card/06.png differ diff --git a/launcher/qml/Components/art/landscapes/card/07.png b/launcher/qml/Components/art/landscapes/card/07.png new file mode 100644 index 00000000..2c470cba Binary files /dev/null and b/launcher/qml/Components/art/landscapes/card/07.png differ diff --git a/launcher/qml/Components/art/landscapes/card/08.png b/launcher/qml/Components/art/landscapes/card/08.png new file mode 100644 index 00000000..3833f64f Binary files /dev/null and b/launcher/qml/Components/art/landscapes/card/08.png differ diff --git a/launcher/qml/Components/art/landscapes/card/09.png b/launcher/qml/Components/art/landscapes/card/09.png new file mode 100644 index 00000000..f4803855 Binary files /dev/null and b/launcher/qml/Components/art/landscapes/card/09.png differ diff --git a/launcher/qml/Components/art/landscapes/card/10.png b/launcher/qml/Components/art/landscapes/card/10.png new file mode 100644 index 00000000..ccc96602 Binary files /dev/null and b/launcher/qml/Components/art/landscapes/card/10.png differ diff --git a/launcher/qml/Components/art/landscapes/card/11.png b/launcher/qml/Components/art/landscapes/card/11.png new file mode 100644 index 00000000..da06cdc4 Binary files /dev/null and b/launcher/qml/Components/art/landscapes/card/11.png differ diff --git a/launcher/qml/Components/art/landscapes/card/12.png b/launcher/qml/Components/art/landscapes/card/12.png new file mode 100644 index 00000000..7a4ed5d7 Binary files /dev/null and b/launcher/qml/Components/art/landscapes/card/12.png differ diff --git a/launcher/qml/Components/art/landscapes/card/13.png b/launcher/qml/Components/art/landscapes/card/13.png new file mode 100644 index 00000000..54675961 Binary files /dev/null and b/launcher/qml/Components/art/landscapes/card/13.png differ diff --git a/launcher/qml/Components/art/landscapes/card/14.png b/launcher/qml/Components/art/landscapes/card/14.png new file mode 100644 index 00000000..1f5cecda Binary files /dev/null and b/launcher/qml/Components/art/landscapes/card/14.png differ diff --git a/launcher/qml/Components/art/landscapes/card/15.png b/launcher/qml/Components/art/landscapes/card/15.png new file mode 100644 index 00000000..4f9ebdd4 Binary files /dev/null and b/launcher/qml/Components/art/landscapes/card/15.png differ diff --git a/launcher/qml/Components/art/landscapes/card/16.png b/launcher/qml/Components/art/landscapes/card/16.png new file mode 100644 index 00000000..3ed7142e Binary files /dev/null and b/launcher/qml/Components/art/landscapes/card/16.png differ diff --git a/launcher/qml/Components/art/landscapes/card/17.png b/launcher/qml/Components/art/landscapes/card/17.png new file mode 100644 index 00000000..596767a7 Binary files /dev/null and b/launcher/qml/Components/art/landscapes/card/17.png differ diff --git a/launcher/qml/Components/art/landscapes/card/18.png b/launcher/qml/Components/art/landscapes/card/18.png new file mode 100644 index 00000000..15ff44e6 Binary files /dev/null and b/launcher/qml/Components/art/landscapes/card/18.png differ diff --git a/launcher/qml/Components/art/landscapes/card/19.png b/launcher/qml/Components/art/landscapes/card/19.png new file mode 100644 index 00000000..710f4ff9 Binary files /dev/null and b/launcher/qml/Components/art/landscapes/card/19.png differ diff --git a/launcher/qml/Components/art/landscapes/card/20.png b/launcher/qml/Components/art/landscapes/card/20.png new file mode 100644 index 00000000..5e9fc059 Binary files /dev/null and b/launcher/qml/Components/art/landscapes/card/20.png differ diff --git a/launcher/qml/Components/art/landscapes/card/21.png b/launcher/qml/Components/art/landscapes/card/21.png new file mode 100644 index 00000000..0d0fdbce Binary files /dev/null and b/launcher/qml/Components/art/landscapes/card/21.png differ diff --git a/launcher/qml/Components/art/landscapes/card/22.png b/launcher/qml/Components/art/landscapes/card/22.png new file mode 100644 index 00000000..e1e1c3e9 Binary files /dev/null and b/launcher/qml/Components/art/landscapes/card/22.png differ diff --git a/launcher/qml/Components/art/landscapes/card/23.png b/launcher/qml/Components/art/landscapes/card/23.png new file mode 100644 index 00000000..f93c9085 Binary files /dev/null and b/launcher/qml/Components/art/landscapes/card/23.png differ diff --git a/launcher/qml/Components/art/landscapes/strip/00.png b/launcher/qml/Components/art/landscapes/strip/00.png new file mode 100644 index 00000000..ea15b543 Binary files /dev/null and b/launcher/qml/Components/art/landscapes/strip/00.png differ diff --git a/launcher/qml/Components/art/landscapes/strip/01.png b/launcher/qml/Components/art/landscapes/strip/01.png new file mode 100644 index 00000000..27af9221 Binary files /dev/null and b/launcher/qml/Components/art/landscapes/strip/01.png differ diff --git a/launcher/qml/Components/art/landscapes/strip/02.png b/launcher/qml/Components/art/landscapes/strip/02.png new file mode 100644 index 00000000..1b924d9d Binary files /dev/null and b/launcher/qml/Components/art/landscapes/strip/02.png differ diff --git a/launcher/qml/Components/art/landscapes/strip/03.png b/launcher/qml/Components/art/landscapes/strip/03.png new file mode 100644 index 00000000..d452b107 Binary files /dev/null and b/launcher/qml/Components/art/landscapes/strip/03.png differ diff --git a/launcher/qml/Components/art/landscapes/strip/04.png b/launcher/qml/Components/art/landscapes/strip/04.png new file mode 100644 index 00000000..e7ee06fa Binary files /dev/null and b/launcher/qml/Components/art/landscapes/strip/04.png differ diff --git a/launcher/qml/Components/art/landscapes/strip/05.png b/launcher/qml/Components/art/landscapes/strip/05.png new file mode 100644 index 00000000..4ff07a7d Binary files /dev/null and b/launcher/qml/Components/art/landscapes/strip/05.png differ diff --git a/launcher/qml/Components/art/landscapes/strip/06.png b/launcher/qml/Components/art/landscapes/strip/06.png new file mode 100644 index 00000000..5a71e72c Binary files /dev/null and b/launcher/qml/Components/art/landscapes/strip/06.png differ diff --git a/launcher/qml/Components/art/landscapes/strip/07.png b/launcher/qml/Components/art/landscapes/strip/07.png new file mode 100644 index 00000000..ac6c9584 Binary files /dev/null and b/launcher/qml/Components/art/landscapes/strip/07.png differ diff --git a/launcher/qml/Components/art/landscapes/strip/08.png b/launcher/qml/Components/art/landscapes/strip/08.png new file mode 100644 index 00000000..22d73871 Binary files /dev/null and b/launcher/qml/Components/art/landscapes/strip/08.png differ diff --git a/launcher/qml/Components/art/landscapes/strip/09.png b/launcher/qml/Components/art/landscapes/strip/09.png new file mode 100644 index 00000000..d1592a34 Binary files /dev/null and b/launcher/qml/Components/art/landscapes/strip/09.png differ diff --git a/launcher/qml/Components/art/landscapes/strip/10.png b/launcher/qml/Components/art/landscapes/strip/10.png new file mode 100644 index 00000000..343a939f Binary files /dev/null and b/launcher/qml/Components/art/landscapes/strip/10.png differ diff --git a/launcher/qml/Components/art/landscapes/strip/11.png b/launcher/qml/Components/art/landscapes/strip/11.png new file mode 100644 index 00000000..52798853 Binary files /dev/null and b/launcher/qml/Components/art/landscapes/strip/11.png differ diff --git a/launcher/qml/Components/art/landscapes/strip/12.png b/launcher/qml/Components/art/landscapes/strip/12.png new file mode 100644 index 00000000..b79a57ce Binary files /dev/null and b/launcher/qml/Components/art/landscapes/strip/12.png differ diff --git a/launcher/qml/Components/art/landscapes/strip/13.png b/launcher/qml/Components/art/landscapes/strip/13.png new file mode 100644 index 00000000..39823ed9 Binary files /dev/null and b/launcher/qml/Components/art/landscapes/strip/13.png differ diff --git a/launcher/qml/Components/art/landscapes/strip/14.png b/launcher/qml/Components/art/landscapes/strip/14.png new file mode 100644 index 00000000..bdd1e517 Binary files /dev/null and b/launcher/qml/Components/art/landscapes/strip/14.png differ diff --git a/launcher/qml/Components/art/landscapes/strip/15.png b/launcher/qml/Components/art/landscapes/strip/15.png new file mode 100644 index 00000000..d2f5f958 Binary files /dev/null and b/launcher/qml/Components/art/landscapes/strip/15.png differ diff --git a/launcher/qml/Components/art/landscapes/strip/16.png b/launcher/qml/Components/art/landscapes/strip/16.png new file mode 100644 index 00000000..c928c2a9 Binary files /dev/null and b/launcher/qml/Components/art/landscapes/strip/16.png differ diff --git a/launcher/qml/Components/art/landscapes/strip/17.png b/launcher/qml/Components/art/landscapes/strip/17.png new file mode 100644 index 00000000..6eff4604 Binary files /dev/null and b/launcher/qml/Components/art/landscapes/strip/17.png differ diff --git a/launcher/qml/Components/art/landscapes/strip/18.png b/launcher/qml/Components/art/landscapes/strip/18.png new file mode 100644 index 00000000..d9109ad5 Binary files /dev/null and b/launcher/qml/Components/art/landscapes/strip/18.png differ diff --git a/launcher/qml/Components/art/landscapes/strip/19.png b/launcher/qml/Components/art/landscapes/strip/19.png new file mode 100644 index 00000000..0de9a7a4 Binary files /dev/null and b/launcher/qml/Components/art/landscapes/strip/19.png differ diff --git a/launcher/qml/Components/art/landscapes/strip/20.png b/launcher/qml/Components/art/landscapes/strip/20.png new file mode 100644 index 00000000..9d4f0bdd Binary files /dev/null and b/launcher/qml/Components/art/landscapes/strip/20.png differ diff --git a/launcher/qml/Components/art/landscapes/strip/21.png b/launcher/qml/Components/art/landscapes/strip/21.png new file mode 100644 index 00000000..9bf7a0a1 Binary files /dev/null and b/launcher/qml/Components/art/landscapes/strip/21.png differ diff --git a/launcher/qml/Components/art/landscapes/strip/22.png b/launcher/qml/Components/art/landscapes/strip/22.png new file mode 100644 index 00000000..7bc25896 Binary files /dev/null and b/launcher/qml/Components/art/landscapes/strip/22.png differ diff --git a/launcher/qml/Components/art/landscapes/strip/23.png b/launcher/qml/Components/art/landscapes/strip/23.png new file mode 100644 index 00000000..cb0d7daf Binary files /dev/null and b/launcher/qml/Components/art/landscapes/strip/23.png differ diff --git a/launcher/qml/Components/art/scenes.py b/launcher/qml/Components/art/scenes.py new file mode 100644 index 00000000..be86aecf --- /dev/null +++ b/launcher/qml/Components/art/scenes.py @@ -0,0 +1,407 @@ +# SPDX-FileCopyrightText: 2026 Project Tick +# SPDX-FileContributor: Project Tick +# SPDX-License-Identifier: Apache-2.0 +""" +Procedural pixel-art landscapes for generate.py: 24 scenes (6 moods x 4 +compositions), each drawn at three shapes so the scene survives whatever band +hosts it -- a 96x54 scene cropped into a 12:1 dock bar only ever samples a +sliver of plain sky (design-plan.md G3c), so a wide host gets a scene drawn +for its own shape rather than a crop of the card one: + + card 48x27 logical (2x baked -> 96x54) grid/jump cards, tiles + band 96x18 logical (2x baked -> 192x36) instance-page hero, ~5:1 + strip 192x16 logical (2x baked -> 384x32) dock bar, ~12:1 + +Index i is mood i // 4, composition i % 4; QML (PixelArt.qml) picks i from a +hash of the instance/world id, so a given item keeps one look at every shape. + +Original artwork only -- no Mojang/Minecraft assets are read or copied. +Deterministic: every scene is built from a fixed random.Random seed. +""" + +import math +import random +from PIL import Image + +SHAPES = {"card": (48, 27), "band": (96, 18), "strip": (192, 16)} +MOOD_ORDER = ("day", "sunset", "night", "snow", "desert", "forest") +COMP_ORDER = ("meadow", "ridge", "lake", "outpost") +SCENE_COUNT = len(MOOD_ORDER) * len(COMP_ORDER) + + +def scene_label(index): + return f"{MOOD_ORDER[index // 4]}/{COMP_ORDER[index % 4]}" + + +def _c8(v): + return max(0, min(255, int(round(v)))) + + +def mix(a, b, t): + return tuple(_c8(a[i] + (b[i] - a[i]) * t) for i in range(3)) + + +def shade(c, f): + return tuple(_c8(v * f) for v in c) + + +def jit(rng, c, spread): + return tuple(_c8(v + rng.randint(-spread, spread)) for v in c) + + +class Canvas: + def __init__(self, w, h): + self.w, self.h = w, h + self.img = Image.new("RGB", (w, h)) + self.px = self.img.load() + + def put(self, x, y, c): + if 0 <= x < self.w and 0 <= y < self.h: + self.px[x, y] = c + + def rect(self, x0, y0, x1, y1, c): + for y in range(max(0, y0), min(self.h, y1)): + for x in range(max(0, x0), min(self.w, x1)): + self.px[x, y] = c + + +def _rgb(hexstr): + return tuple(int(hexstr[i:i + 2], 16) for i in (0, 2, 4)) + + +# sky: (position, colour) bands, drawn as solid strips with a one-row +# checker dither at each seam -- pixel-art skies are banded, not smooth. +MOODS = { + "day": dict( + sky=[(0.00, _rgb("8FBEE0")), (0.42, _rgb("A9D2EA")), (0.68, _rgb("C9E4F2"))], + far=_rgb("5C8F5B"), near=_rgb("42733F"), ground=_rgb("34592F"), tuft=_rgb("5E9450"), + peak=_rgb("7C93A6"), cap=_rgb("F0F4F6"), accent=_rgb("F4D35E"), cloud=_rgb("F6F6F6"), + water=_rgb("4F8FB8"), water_hi=_rgb("9CCBE4"), stars=0, clouds=True, + tree=_rgb("2C5533"), tree_hi=_rgb("3F7444"), trunk=_rgb("5A3E26"), + wall=_rgb("B98A55"), roof=_rgb("8A3F34"), door=_rgb("4A3320"), window=None, + tree_style="round", moon=False, + ), + "sunset": dict( + sky=[(0.00, _rgb("3E3E6C")), (0.30, _rgb("7C567A")), (0.52, _rgb("C86F62")), (0.72, _rgb("E8965E"))], + far=_rgb("48394A"), near=_rgb("2C222E"), ground=_rgb("1C1620"), tuft=_rgb("3A2C3A"), + peak=_rgb("54405A"), cap=_rgb("D08A78"), accent=_rgb("F6BC58"), cloud=_rgb("E08C74"), + water=_rgb("6A4A6A"), water_hi=_rgb("EAA060"), stars=0, clouds=True, + tree=_rgb("1E1722"), tree_hi=_rgb("2E2230"), trunk=_rgb("241A20"), + wall=_rgb("3C2C34"), roof=_rgb("241820"), door=_rgb("140E14"), window=_rgb("F2BE62"), + tree_style="round", moon=False, + ), + "night": dict( + sky=[(0.00, _rgb("0A0F20")), (0.45, _rgb("121B34")), (0.72, _rgb("1C2A46"))], + far=_rgb("1A2634"), near=_rgb("111A22"), ground=_rgb("0B1116"), tuft=_rgb("1C2A32"), + peak=_rgb("243048"), cap=_rgb("6E80A2"), accent=_rgb("E2EAF4"), cloud=_rgb("2A3854"), + water=_rgb("122036"), water_hi=_rgb("4C6488"), stars=1, clouds=False, + tree=_rgb("0B1418"), tree_hi=_rgb("14222A"), trunk=_rgb("0A1014"), + wall=_rgb("1C2632"), roof=_rgb("0E151C"), door=_rgb("080C10"), window=_rgb("F2C86A"), + tree_style="pine", moon=True, + ), + "snow": dict( + sky=[(0.00, _rgb("B4C8D8")), (0.42, _rgb("CFDDE7")), (0.70, _rgb("E5EDF2"))], + far=_rgb("BCCBD6"), near=_rgb("DDE7ED"), ground=_rgb("EEF3F6"), tuft=_rgb("FFFFFF"), + peak=_rgb("8FA3B6"), cap=_rgb("FCFDFE"), accent=_rgb("F4EFE0"), cloud=_rgb("F7FAFB"), + water=_rgb("9EC2D4"), water_hi=_rgb("DCEEF6"), stars=0, clouds=True, + tree=_rgb("2A4A40"), tree_hi=_rgb("F2F7F8"), trunk=_rgb("4A362A"), + wall=_rgb("9A6E48"), roof=_rgb("F4F8FA"), door=_rgb("4A3322"), window=_rgb("F0C468"), + tree_style="pine", moon=False, + ), + "desert": dict( + sky=[(0.00, _rgb("E1CB92")), (0.44, _rgb("EBDBAC")), (0.70, _rgb("F4E9C6"))], + far=_rgb("D2B168"), near=_rgb("B99552"), ground=_rgb("9A783C"), tuft=_rgb("D8BA74"), + peak=_rgb("B4694A"), cap=_rgb("D88E62"), accent=_rgb("F4C450"), cloud=_rgb("F8EFD6"), + water=_rgb("46969E"), water_hi=_rgb("9ED4D6"), stars=0, clouds=False, + tree=_rgb("46703E"), tree_hi=_rgb("62924F"), trunk=_rgb("6A4A2E"), + wall=_rgb("D6B47C"), roof=_rgb("B48E58"), door=_rgb("4A3320"), window=None, + tree_style="cactus", moon=False, + ), + "forest": dict( + sky=[(0.00, _rgb("8DB6B6")), (0.44, _rgb("ACCDC7")), (0.70, _rgb("C8E0D9"))], + far=_rgb("3F6E44"), near=_rgb("2B5231"), ground=_rgb("1E3A24"), tuft=_rgb("3C7040"), + peak=_rgb("4F7472"), cap=_rgb("BFD4CF"), accent=_rgb("F2EBC0"), cloud=_rgb("E6F0EC"), + water=_rgb("3C7883"), water_hi=_rgb("8AB8BA"), stars=0, clouds=False, + tree=_rgb("1B3B23"), tree_hi=_rgb("2E5A34"), trunk=_rgb("4A3524"), + wall=_rgb("8A6238"), roof=_rgb("4A2E28"), door=_rgb("2E2016"), window=_rgb("F0C468"), + tree_style="pine", moon=False, + ), +} + +# Four compositions, each a different silhouette rather than a re-scatter of +# the same one: horizon height, the far layer's kind, the near layer's shape +# and what stands on it, and where the sun/moon sits. +COMPS = { + "meadow": dict(horizon=0.68, ax=0.78, ay=0.16, far="hills", far_amp=1.7, near_off=3.4, + near_amp=1.5, trees=7, clouds=3, star_mul=1.0, water=False, huts=0), + "ridge": dict(horizon=0.64, ax=0.20, ay=0.20, far="peaks", far_amp=11.0, near_off=4.0, + near_amp=1.1, trees=5, clouds=2, star_mul=1.5, water=False, huts=0), + "lake": dict(horizon=0.50, ax=0.55, ay=0.14, far="hills", far_amp=1.9, near_off=4.0, + near_amp=0.8, trees=4, clouds=2, star_mul=0.9, water=True, huts=0), + "outpost": dict(horizon=0.60, ax=0.34, ay=0.34, far="hills", far_amp=2.6, near_off=3.8, + near_amp=0.9, trees=3, clouds=1, star_mul=0.7, water=False, huts=3), +} +# The desert has no rolling green hills: dunes, mesas and an oasis instead. +DESERT_FAR = {"meadow": "dunes", "ridge": "mesa", "lake": "dunes", "outpost": "dunes"} + + +def _sky(cv, bands): + """Solid horizontal strips whose colours follow the mood's anchor bands + -- pixel-art skies are banded rather than smooth, but with enough strips + that no single seam reads as a hard line.""" + def smooth(pos): + lo = bands[0] + for b in bands: + if pos >= b[0]: + lo = b + hi = next((b for b in bands if b[0] > lo[0]), None) + if hi is None: + return lo[1] + return mix(lo[1], hi[1], (pos - lo[0]) / (hi[0] - lo[0])) + + strips = max(4, cv.h // 3) + rows = {} + for y in range(cv.h): + k = min(strips - 1, int(y / cv.h * strips)) + rows[y] = smooth((k + 0.5) / strips * 0.78) + for x in range(cv.w): + cv.px[x, y] = rows[y] + return lambda y: rows[max(0, min(cv.h - 1, y))] + + +def _disc(cv, cx, cy, r, color): + n = int(math.ceil(r)) + 1 + for dy in range(-n, n + 1): + for dx in range(-n, n + 1): + if dx * dx + dy * dy <= r * r + 0.6: + cv.put(cx + dx, cy + dy, color) + + +def _cloud(cv, x, y, cw, color, shadow): + cv.rect(x + 1, y, x + cw - 1, y + 1, color) + cv.rect(x, y + 1, x + cw, y + 2, color) + cv.rect(x + 1, y + 2, x + cw - 2, y + 3, shadow) + + +def _rolling(w, h, base, amp, rng): + scale = (w / h) / (48 / 27) + waves = [(rng.uniform(0.7, 1.3) * scale, rng.uniform(0, 6.28), 0.60), + (rng.uniform(1.8, 2.8) * scale, rng.uniform(0, 6.28), 0.30), + (rng.uniform(4.0, 6.0) * scale, rng.uniform(0, 6.28), 0.10)] + return [int(round(base + amp * sum(a * math.sin(2 * math.pi * f * x / w + p) for f, p, a in waves))) + for x in range(w)] + + +def _fill_layer(cv, tops, color, edge=None): + for x, t in enumerate(tops): + for y in range(max(0, t), cv.h): + cv.px[x, y] = color + if edge is not None: + cv.put(x, t, edge) + + +def _peaks(cv, base_y, height, count, color, cap, rng): + w = cv.w + n = max(2, round(count * w / 48)) + peaks = [] + for i in range(n): + cx = (i + 0.5) * w / n + rng.uniform(-0.22, 0.22) * w / n + ph = height * rng.uniform(0.62, 1.0) + peaks.append((cx, ph, ph * rng.uniform(1.05, 1.55))) + for x in range(w): + best = None + for cx, ph, hw in peaks: + top = base_y - ph * (1 - abs(x - cx) / hw) + if abs(x - cx) < hw and (best is None or top < best[0]): + best = (top, cx, ph) + top = int(round(best[0])) if best else base_y + tone = shade(color, 0.86) if best and x > best[1] else color + for y in range(max(0, min(top, base_y)), cv.h): + cv.px[x, y] = tone + if best and (base_y - top) >= best[2] * (0.68 + rng.choice((-0.06, 0, 0, 0.06))): + for y in range(max(0, top), max(0, top) + max(1, round((base_y - top) - best[2] * 0.62))): + cv.put(x, y, cap if x <= best[1] else shade(cap, 0.9)) + + +def _mesas(cv, base_y, height, count, color, rng): + w = cv.w + n = max(2, round(count * w / 48)) + stripe = shade(color, 0.86) + for i in range(n): + cx = int((i + 0.5) * w / n + rng.uniform(-0.2, 0.2) * w / n) + mh = max(3, int(height * rng.uniform(0.55, 1.0))) + top_half = max(2, int(mh * rng.uniform(0.55, 0.9))) + for k in range(mh): + half = top_half + (k * 2) // 3 + (k // 3) + y = base_y - mh + k + c = stripe if (k // 2) % 2 else color + for x in range(cx - half, cx + half + 1): + cv.put(x, y, c) + for y in range(base_y, cv.h): + for x in range(w): + cv.px[x, y] = color + + +def _tree(cv, x, base_y, s, style, m, rng): + canopy, hi, trunk = m["tree"], m["tree_hi"], m["trunk"] + if style == "cactus": + col = m["tree"] + for dy in range(s + 1): + cv.put(x, base_y - 1 - dy, col) + if s >= 4: + cv.put(x - 1, base_y - 1 - s // 2, col) + cv.put(x - 1, base_y - 2 - s // 2, col) + cv.put(x + 1, base_y - 2 - s // 2, col) + cv.put(x + 1, base_y - 3 - s // 2, col) + cv.put(x, base_y - 1 - s, m["tree_hi"]) + return + if style == "palm": + th = s + 1 + for dy in range(th): + cv.put(x + (1 if dy > th // 2 else 0), base_y - 1 - dy, trunk) + tx, ty = x + 1, base_y - th + for dx, dy in ((-2, 0), (-1, -1), (0, -1), (1, -1), (2, 0), (-1, 0), (3, 1), (-3, 1)): + cv.put(tx + dx, ty + dy, canopy) + cv.put(tx, ty - 1, hi) + return + if style == "pine": + cv.put(x, base_y - 1, trunk) + top = base_y - 1 - s + for dy in range(s): + half = ((dy + 1) * (s // 2 + 1)) // s + for dx in range(-half, half + 1): + cv.put(x + dx, top + dy, hi if (dx < 0 and dy % 2 == 0) else canopy) + return + th = max(1, s // 3) + for dy in range(th): + cv.put(x, base_y - 1 - dy, trunk) + top = base_y - th - s + rx, ry = (s + 1) / 2.0, s / 2.0 + for dy in range(s): + for dx in range(-(s // 2) - 1, s // 2 + 2): + if (dx / rx) ** 2 + ((dy - ry + 0.5) / ry) ** 2 <= 1.0: + cv.put(x + dx, top + dy, hi if (dy < s / 3 and dx <= 0) else canopy) + + +def _hut(cv, x, base_y, u, m, flat): + bw = max(4, round(6 * u)) + bh = max(3, round(4 * u)) + x0 = x - bw // 2 + cv.rect(x0, base_y - bh, x0 + bw, base_y, m["wall"]) + if flat: + cv.rect(x0 - 1, base_y - bh - 1, x0 + bw + 1, base_y - bh, m["roof"]) + else: + rh = max(2, (bw + 1) // 2) + for k in range(rh): + if x0 + bw + 1 - k <= x0 - 1 + k: + break + cv.rect(x0 - 1 + k, base_y - bh - 1 - k, x0 + bw + 1 - k, base_y - bh - k, m["roof"]) + cv.put(x0 + bw // 2, base_y - 1, m["door"]) + if bh >= 3: + cv.put(x0 + bw // 2, base_y - 2, m["door"]) + if bw >= 5: + cv.put(x0 + 1, base_y - bh + 1, m["window"] or shade(m["wall"], 0.7)) + + +def render(index, shape): + mood_name = MOOD_ORDER[index // 4] + comp_name = COMP_ORDER[index % 4] + m, comp = MOODS[mood_name], COMPS[comp_name] + w, h = SHAPES[shape] + rng = random.Random(7000 + index * 31 + list(SHAPES).index(shape)) + u = h / 27.0 + cv = Canvas(w, h) + sky_at = _sky(cv, m["sky"]) + + horizon = round(h * comp["horizon"]) + ax = int(w * comp["ax"]) + rng.randint(-2, 2) + ay = int(h * comp["ay"]) + if mood_name == "sunset": + ay = min(int(h * (comp["ay"] + 0.16)), horizon - 1) + r = max(1.5, 2.3 * u) + + for _ in range(round(m["stars"] * comp["star_mul"] * w * h / 64)): + sx, sy = rng.randint(0, w - 1), rng.randint(0, max(1, horizon - 2)) + cv.put(sx, sy, mix(m["accent"], sky_at(sy), rng.choice((0.0, 0.35, 0.55)))) + _disc(cv, ax, ay, r, m["accent"]) + if m["moon"] and comp_name in ("ridge", "outpost"): + _disc(cv, ax + 1, ay - 1, r * 0.9, sky_at(ay)) + + if m["clouds"]: + for _ in range(round(comp["clouds"] * w / 48)): + cx = rng.randint(1, w - 9) + cy = rng.randint(1, max(2, int(horizon * 0.5))) + _cloud(cv, cx, cy, rng.randint(max(4, int(5 * u * 1.5)), max(6, int(9 * u * 1.5))), + m["cloud"], mix(m["cloud"], sky_at(cy + 2), 0.35)) + + far_kind = DESERT_FAR[comp_name] if mood_name == "desert" else comp["far"] + far_amp = comp["far_amp"] * u + if far_kind == "peaks": + _peaks(cv, horizon + 1, far_amp, 3, m["peak"], m["cap"], rng) + far_tops = [horizon + 1] * w + elif far_kind == "mesa": + _mesas(cv, horizon + 1, far_amp * 0.9, 3, m["peak"], rng) + far_tops = [horizon + 1] * w + else: + amp = far_amp * (1.6 if far_kind == "dunes" else 1.0) + far_tops = _rolling(w, h, horizon, amp, rng) + _fill_layer(cv, far_tops, m["far"], mix(m["far"], sky_at(max(0, horizon)), 0.28)) + + fg = max(2, round(2.5 * u)) + fg_top = h - fg + water_top = None + if comp["water"]: + water_top = min(fg_top - 1, max(far_tops) + 1) + for y in range(water_top, fg_top): + for x in range(w): + cv.px[x, y] = m["water"] + for y in range(water_top, fg_top): + for _ in range(w // 10): + dx = rng.randint(0, w - 4) + for k in range(rng.randint(2, 4)): + cv.put(dx + k, y, mix(m["water"], m["water_hi"], 0.55)) + for k, y in enumerate(range(water_top, fg_top)): + cv.put(ax + (k % 2), y, m["water_hi"]) + cv.put(ax - 2 + (k % 3), y, mix(m["water"], m["water_hi"], 0.7)) + for x in range(w): + cv.px[x, water_top] = mix(m["water"], sky_at(water_top), 0.35) + + near_base = horizon + round(comp["near_off"] * u) + if comp["water"]: + bank = max(2, (fg_top - water_top) * 0.9) + near_tops = [] + for x in range(w): + t = abs(x - w * 0.5) / (w * 0.5) + lift = bank * (max(0.0, t - 0.42) / 0.58) ** 1.4 + near_tops.append(int(round(fg_top - lift + rng.choice((0, 0, 0, -1))))) + else: + near_tops = _rolling(w, h, near_base, comp["near_amp"] * u, rng) + _fill_layer(cv, near_tops, m["near"], mix(m["near"], m["tuft"], 0.5)) + + def stand_on(x): + return max(0, min(w - 1, x)) + + if comp["huts"]: + step = w / comp["huts"] + for i in range(comp["huts"]): + hx = stand_on(int(step * (i + 0.5) + rng.uniform(-0.18, 0.18) * step)) + _hut(cv, hx, near_tops[hx] + 1, u, m, flat=(mood_name == "desert")) + + style = "palm" if (mood_name == "desert" and comp_name == "lake") else m["tree_style"] + n_trees = max(1, round(comp["trees"] * w / 48 * (0.75 if w > 48 else 1.0))) + for _ in range(n_trees): + tx = rng.randint(2, w - 3) + base_y = near_tops[tx] + rng.randint(0, 1) + if base_y >= fg_top: + continue + size = max(3, round(5.6 * u * rng.uniform(0.8, 1.25))) + _tree(cv, tx, base_y, size, style, m, rng) + + for y in range(fg_top, h): + for x in range(w): + cv.px[x, y] = jit(rng, m["ground"], 4) + for x in range(w): + cv.px[x, fg_top] = m["tuft"] + if rng.random() < 0.2: + cv.put(x, fg_top - 1, m["tuft"]) + return cv.img diff --git a/launcher/qml/Components/icons/LICENSE.Lucide.txt b/launcher/qml/Components/icons/LICENSE.Lucide.txt new file mode 100644 index 00000000..b398d28b --- /dev/null +++ b/launcher/qml/Components/icons/LICENSE.Lucide.txt @@ -0,0 +1,17 @@ +ISC License + +Copyright (c) for portions of Lucide are held by Cole Bemis 2013-2022 as part +of Feather (MIT). All other copyright (c) for Lucide are held by Lucide +Contributors 2022. + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. diff --git a/launcher/qml/Components/icons/alert-triangle.svg b/launcher/qml/Components/icons/alert-triangle.svg new file mode 100644 index 00000000..c2d7a570 --- /dev/null +++ b/launcher/qml/Components/icons/alert-triangle.svg @@ -0,0 +1 @@ + diff --git a/launcher/qml/Components/icons/archive.svg b/launcher/qml/Components/icons/archive.svg new file mode 100644 index 00000000..c76fde5a --- /dev/null +++ b/launcher/qml/Components/icons/archive.svg @@ -0,0 +1 @@ + diff --git a/launcher/qml/Components/icons/arrow-down.svg b/launcher/qml/Components/icons/arrow-down.svg new file mode 100644 index 00000000..cedce22f --- /dev/null +++ b/launcher/qml/Components/icons/arrow-down.svg @@ -0,0 +1 @@ + diff --git a/launcher/qml/Components/icons/arrow-up.svg b/launcher/qml/Components/icons/arrow-up.svg new file mode 100644 index 00000000..69fe3c51 --- /dev/null +++ b/launcher/qml/Components/icons/arrow-up.svg @@ -0,0 +1 @@ + diff --git a/launcher/qml/Components/icons/bell.svg b/launcher/qml/Components/icons/bell.svg new file mode 100644 index 00000000..09f7dcd2 --- /dev/null +++ b/launcher/qml/Components/icons/bell.svg @@ -0,0 +1 @@ + diff --git a/launcher/qml/Components/icons/check.svg b/launcher/qml/Components/icons/check.svg new file mode 100644 index 00000000..578b7f4d --- /dev/null +++ b/launcher/qml/Components/icons/check.svg @@ -0,0 +1 @@ + diff --git a/launcher/qml/Components/icons/chevron-down.svg b/launcher/qml/Components/icons/chevron-down.svg new file mode 100644 index 00000000..043b08cf --- /dev/null +++ b/launcher/qml/Components/icons/chevron-down.svg @@ -0,0 +1 @@ + diff --git a/launcher/qml/Components/icons/chevron-left.svg b/launcher/qml/Components/icons/chevron-left.svg new file mode 100644 index 00000000..be361d59 --- /dev/null +++ b/launcher/qml/Components/icons/chevron-left.svg @@ -0,0 +1 @@ + diff --git a/launcher/qml/Components/icons/chevron-right.svg b/launcher/qml/Components/icons/chevron-right.svg new file mode 100644 index 00000000..bd19674f --- /dev/null +++ b/launcher/qml/Components/icons/chevron-right.svg @@ -0,0 +1 @@ + diff --git a/launcher/qml/Components/icons/clock.svg b/launcher/qml/Components/icons/clock.svg new file mode 100644 index 00000000..c465a4db --- /dev/null +++ b/launcher/qml/Components/icons/clock.svg @@ -0,0 +1 @@ + diff --git a/launcher/qml/Components/icons/compass.svg b/launcher/qml/Components/icons/compass.svg new file mode 100644 index 00000000..79ab6e0f --- /dev/null +++ b/launcher/qml/Components/icons/compass.svg @@ -0,0 +1 @@ + diff --git a/launcher/qml/Components/icons/copy.svg b/launcher/qml/Components/icons/copy.svg new file mode 100644 index 00000000..634b3f58 --- /dev/null +++ b/launcher/qml/Components/icons/copy.svg @@ -0,0 +1 @@ + diff --git a/launcher/qml/Components/icons/cube.svg b/launcher/qml/Components/icons/cube.svg new file mode 100644 index 00000000..77cf2152 --- /dev/null +++ b/launcher/qml/Components/icons/cube.svg @@ -0,0 +1 @@ + diff --git a/launcher/qml/Components/icons/download.svg b/launcher/qml/Components/icons/download.svg new file mode 100644 index 00000000..6dc2cfe5 --- /dev/null +++ b/launcher/qml/Components/icons/download.svg @@ -0,0 +1 @@ + diff --git a/launcher/qml/Components/icons/edit.svg b/launcher/qml/Components/icons/edit.svg new file mode 100644 index 00000000..8ab06bd0 --- /dev/null +++ b/launcher/qml/Components/icons/edit.svg @@ -0,0 +1 @@ + diff --git a/launcher/qml/Components/icons/external-link.svg b/launcher/qml/Components/icons/external-link.svg new file mode 100644 index 00000000..52158478 --- /dev/null +++ b/launcher/qml/Components/icons/external-link.svg @@ -0,0 +1 @@ + diff --git a/launcher/qml/Components/icons/folder.svg b/launcher/qml/Components/icons/folder.svg new file mode 100644 index 00000000..d620ad2f --- /dev/null +++ b/launcher/qml/Components/icons/folder.svg @@ -0,0 +1 @@ + diff --git a/launcher/qml/Components/icons/globe.svg b/launcher/qml/Components/icons/globe.svg new file mode 100644 index 00000000..83009cce --- /dev/null +++ b/launcher/qml/Components/icons/globe.svg @@ -0,0 +1 @@ + diff --git a/launcher/qml/Components/icons/grid.svg b/launcher/qml/Components/icons/grid.svg new file mode 100644 index 00000000..3e805706 --- /dev/null +++ b/launcher/qml/Components/icons/grid.svg @@ -0,0 +1 @@ + diff --git a/launcher/qml/Components/icons/home.svg b/launcher/qml/Components/icons/home.svg new file mode 100644 index 00000000..1c2acf45 --- /dev/null +++ b/launcher/qml/Components/icons/home.svg @@ -0,0 +1 @@ + diff --git a/launcher/qml/Components/icons/image.svg b/launcher/qml/Components/icons/image.svg new file mode 100644 index 00000000..e6cf3e0f --- /dev/null +++ b/launcher/qml/Components/icons/image.svg @@ -0,0 +1 @@ + diff --git a/launcher/qml/Components/icons/info.svg b/launcher/qml/Components/icons/info.svg new file mode 100644 index 00000000..7071adb2 --- /dev/null +++ b/launcher/qml/Components/icons/info.svg @@ -0,0 +1 @@ + diff --git a/launcher/qml/Components/icons/layers.svg b/launcher/qml/Components/icons/layers.svg new file mode 100644 index 00000000..ce770a2f --- /dev/null +++ b/launcher/qml/Components/icons/layers.svg @@ -0,0 +1 @@ + diff --git a/launcher/qml/Components/icons/library.svg b/launcher/qml/Components/icons/library.svg new file mode 100644 index 00000000..772dbbc0 --- /dev/null +++ b/launcher/qml/Components/icons/library.svg @@ -0,0 +1 @@ + diff --git a/launcher/qml/Components/icons/list.svg b/launcher/qml/Components/icons/list.svg new file mode 100644 index 00000000..0691285b --- /dev/null +++ b/launcher/qml/Components/icons/list.svg @@ -0,0 +1 @@ + diff --git a/launcher/qml/Components/icons/log-out.svg b/launcher/qml/Components/icons/log-out.svg new file mode 100644 index 00000000..4bbe7dab --- /dev/null +++ b/launcher/qml/Components/icons/log-out.svg @@ -0,0 +1 @@ + diff --git a/launcher/qml/Components/icons/moon.svg b/launcher/qml/Components/icons/moon.svg new file mode 100644 index 00000000..b5537637 --- /dev/null +++ b/launcher/qml/Components/icons/moon.svg @@ -0,0 +1 @@ + diff --git a/launcher/qml/Components/icons/more.svg b/launcher/qml/Components/icons/more.svg new file mode 100644 index 00000000..380ed4f9 --- /dev/null +++ b/launcher/qml/Components/icons/more.svg @@ -0,0 +1 @@ + diff --git a/launcher/qml/Components/icons/package.svg b/launcher/qml/Components/icons/package.svg new file mode 100644 index 00000000..4805fe90 --- /dev/null +++ b/launcher/qml/Components/icons/package.svg @@ -0,0 +1 @@ + diff --git a/launcher/qml/Components/icons/play.svg b/launcher/qml/Components/icons/play.svg new file mode 100644 index 00000000..dd1a1ec4 --- /dev/null +++ b/launcher/qml/Components/icons/play.svg @@ -0,0 +1 @@ + diff --git a/launcher/qml/Components/icons/plus.svg b/launcher/qml/Components/icons/plus.svg new file mode 100644 index 00000000..ed9c2bad --- /dev/null +++ b/launcher/qml/Components/icons/plus.svg @@ -0,0 +1 @@ + diff --git a/launcher/qml/Components/icons/refresh.svg b/launcher/qml/Components/icons/refresh.svg new file mode 100644 index 00000000..7c18f32a --- /dev/null +++ b/launcher/qml/Components/icons/refresh.svg @@ -0,0 +1 @@ + diff --git a/launcher/qml/Components/icons/search.svg b/launcher/qml/Components/icons/search.svg new file mode 100644 index 00000000..d082a1d1 --- /dev/null +++ b/launcher/qml/Components/icons/search.svg @@ -0,0 +1 @@ + diff --git a/launcher/qml/Components/icons/server.svg b/launcher/qml/Components/icons/server.svg new file mode 100644 index 00000000..9250048a --- /dev/null +++ b/launcher/qml/Components/icons/server.svg @@ -0,0 +1 @@ + diff --git a/launcher/qml/Components/icons/settings.svg b/launcher/qml/Components/icons/settings.svg new file mode 100644 index 00000000..403c546e --- /dev/null +++ b/launcher/qml/Components/icons/settings.svg @@ -0,0 +1 @@ + diff --git a/launcher/qml/Components/icons/sort.svg b/launcher/qml/Components/icons/sort.svg new file mode 100644 index 00000000..7d081890 --- /dev/null +++ b/launcher/qml/Components/icons/sort.svg @@ -0,0 +1 @@ + diff --git a/launcher/qml/Components/icons/stop.svg b/launcher/qml/Components/icons/stop.svg new file mode 100644 index 00000000..668381f8 --- /dev/null +++ b/launcher/qml/Components/icons/stop.svg @@ -0,0 +1 @@ + diff --git a/launcher/qml/Components/icons/sun.svg b/launcher/qml/Components/icons/sun.svg new file mode 100644 index 00000000..63ac3841 --- /dev/null +++ b/launcher/qml/Components/icons/sun.svg @@ -0,0 +1 @@ + diff --git a/launcher/qml/Components/icons/terminal.svg b/launcher/qml/Components/icons/terminal.svg new file mode 100644 index 00000000..16ebc547 --- /dev/null +++ b/launcher/qml/Components/icons/terminal.svg @@ -0,0 +1 @@ + diff --git a/launcher/qml/Components/icons/trash.svg b/launcher/qml/Components/icons/trash.svg new file mode 100644 index 00000000..7bbd92df --- /dev/null +++ b/launcher/qml/Components/icons/trash.svg @@ -0,0 +1 @@ + diff --git a/launcher/qml/Components/icons/user.svg b/launcher/qml/Components/icons/user.svg new file mode 100644 index 00000000..95230309 --- /dev/null +++ b/launcher/qml/Components/icons/user.svg @@ -0,0 +1 @@ + diff --git a/launcher/qml/Components/icons/users.svg b/launcher/qml/Components/icons/users.svg new file mode 100644 index 00000000..826f762b --- /dev/null +++ b/launcher/qml/Components/icons/users.svg @@ -0,0 +1 @@ + diff --git a/launcher/qml/Components/icons/x.svg b/launcher/qml/Components/icons/x.svg new file mode 100644 index 00000000..a0688d82 --- /dev/null +++ b/launcher/qml/Components/icons/x.svg @@ -0,0 +1 @@ + diff --git a/launcher/qml/InstanceIconProvider.cpp b/launcher/qml/InstanceIconProvider.cpp new file mode 100644 index 00000000..e0c98bd2 --- /dev/null +++ b/launcher/qml/InstanceIconProvider.cpp @@ -0,0 +1,69 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "InstanceIconProvider.h" + +#include + +namespace +{ +// Only a fallback for an unrecognised or zero requestedSize; the exact value +// is not load-bearing anywhere else. +constexpr int kDefaultIconExtent = 64; +} + +InstanceIconProvider::InstanceIconProvider(std::shared_ptr icons) + : QQuickImageProvider(QQuickImageProvider::Pixmap), m_icons(std::move(icons)) +{ +} + +QPixmap InstanceIconProvider::requestPixmap(const QString& id, QSize* size, + const QSize& requestedSize) +{ + // Cache-busting query ("?rev=N") plays no part in the lookup -- see + // the header for why it exists at all. + const int queryStart = id.indexOf(QLatin1Char('?')); + const QString key = queryStart < 0 ? id : id.left(queryStart); + + // requestedSize is already in device pixels -- QML applied the device + // pixel ratio before calling here -- so it is used as-is, never an + // invalid or empty one. + const QSize wanted = requestedSize.isEmpty() + ? QSize(kDefaultIconExtent, kDefaultIconExtent) + : requestedSize; + + QPixmap pixmap; + if (m_icons) { + // getIcon() already falls back to the "grass" builtin for an unknown + // key (and for "default"); nothing extra to do for that here. + pixmap = m_icons->getIcon(key).pixmap(wanted); + } + + if (pixmap.isNull()) { + // Reached only if no IconList was given, or even "grass" could not + // be found (e.g. the icon theme resource was never registered). A + // null QPixmap here would reach QML as a broken image, so this + // draws something instead of ever returning one. + pixmap = QPixmap(wanted); + pixmap.fill(Qt::gray); + } + + *size = pixmap.size(); + return pixmap; +} diff --git a/launcher/qml/InstanceIconProvider.h b/launcher/qml/InstanceIconProvider.h new file mode 100644 index 00000000..6c03d13a --- /dev/null +++ b/launcher/qml/InstanceIconProvider.h @@ -0,0 +1,94 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include + +#include + +#include + +/* + * Bridges IconList (QIcon, launcher/icons/IconList.h) into QML, which has no + * way to display a QIcon. A delegate writes + * + * Image { source: "image://instanceicon/" + iconKey } + * + * where iconKey is the string role InstanceList::roleNames() exposes for + * Qt::DecorationRole (see InstanceList.h/.cpp) -- the same key + * InstanceProxyModel::data() already resolves through IconList::getIcon() + * for the QtWidgets grid. + * + * THREADING. This derives from QQuickImageProvider with ImageType::Pixmap, + * not QQuickAsyncImageProvider and not ImageType::Image. Qt Quick only + * guarantees a Pixmap-type provider's requestPixmap() is called on the GUI + * thread; ImageType::Image can be serviced from a loader thread once the + * engine's threaded image loading kicks in, and QQuickAsyncImageProvider is + * explicitly meant to do its work off-thread. QIcon and QPixmap are GUI-only + * types -- constructing, copying or painting either off the GUI thread is + * undefined behaviour -- so Pixmap is the only one of the three that keeps + * this code where it is safe to touch them. + * + * SIZE. requestedSize arrives already in device pixels; QML has already + * folded in the device pixel ratio before calling requestPixmap(), so it is + * used as-is here, with no extra multiplication. An invalid or empty + * requestedSize (the default QSize(), or an explicit zero) falls back to a + * fixed default extent instead. *size is always set to the pixmap actually + * returned, which is not necessarily requestedSize. + * + * CACHE-BUSTING. QML caches a resolved image:/// URL by id, so + * once IconList::iconUpdated(key) fires there is otherwise no way to make an + * already-bound Image re-fetch the same key. id may carry a "?rev=N" + * query string; requestPixmap() strips everything from the first '?' onward + * before looking the key up, so N has no effect on which icon comes back -- + * it exists purely so a caller can change it to make QML treat the URL as + * new. Bumping N when iconUpdated fires is left to whoever wires this + * provider into the engine. + * + * UNKNOWN KEYS. An unknown or empty key is not special-cased here: + * IconList::getIcon() already falls back to the "grass" builtin for those + * (and for the literal key "default"), which is the same fallback + * InstanceProxyModel::data() relies on today. Only if that also somehow + * comes back null (no IconList given, or "grass" itself unavailable) does + * this provider draw a plain fallback pixmap of its own, so a caller never + * receives a null QPixmap. + * + * OWNERSHIP. Takes the IconList as a std::shared_ptr rather than a raw + * pointer because that is how the rest of the launcher already holds it -- + * Application::icons() returns std::shared_ptr, not a + * QObject-parented instance -- so this provider shares the same object + * instead of assuming a global. It reaches for neither APPLICATION nor + * LAUNCHER: the list is injected by whoever constructs the provider, which + * is what makes it constructible with a throwaway IconList in a test. + */ +class InstanceIconProvider : public QQuickImageProvider +{ + public: + explicit InstanceIconProvider(std::shared_ptr icons); + + QPixmap requestPixmap(const QString& id, QSize* size, + const QSize& requestedSize) override; + + private: + std::shared_ptr m_icons; +}; diff --git a/launcher/qml/InstanceIconProvider_test.cpp b/launcher/qml/InstanceIconProvider_test.cpp new file mode 100644 index 00000000..2c901677 --- /dev/null +++ b/launcher/qml/InstanceIconProvider_test.cpp @@ -0,0 +1,187 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +#include "InstanceIconProvider.h" + +namespace +{ +/* The same four directories Application.cpp (and IconTheme_test.cpp) pass to + * IconList. All four are needed for "grass" specifically: of the built-in + * instance icons, only scalable/instances/ ships an unsuffixed "grass.svg" + * -- the raster sizes only have "grass_legacy.png" -- so leaving scalable + * out would make every lookup below fall through to the fallback path and + * prove nothing. */ +const QStringList kBuiltinIconDirs = { + QStringLiteral(":/icons/multimc/32x32/instances/"), + QStringLiteral(":/icons/multimc/50x50/instances/"), + QStringLiteral(":/icons/multimc/128x128/instances/"), + QStringLiteral(":/icons/multimc/scalable/instances/"), +}; + +// Mirrors InstanceIconProvider's own fallback extent. +constexpr int kDefaultIconExtent = 64; +} // namespace + +/* + * Unit test for InstanceIconProvider. + * + * Runs against a real IconList rather than a mock: the point of this + * provider is the "?rev=N" bridge and the unknown-key/invalid-size + * fallbacks, and a mock would only prove it calls a method, not that the + * right pixels come back. Getting a real "grass" builtin icon to resolve + * here needs the same two things application start-up does, and both are + * easy to skip silently: + * + * - Q_INIT_RESOURCE(multimc) in main(), because the compiled "multimc" + * resource lives inside the MeshMC_logic static library and nothing else + * in this test binary forces the linker to keep those object files. + * - QIcon::setThemeName("multimc") over a search path that includes + * ":/icons", because IconList's Builtin icons resolve through + * QIcon::fromTheme() rather than loading the file directly -- see + * MMCIcon::icon() -- which is exactly what ThemeManager::setIconTheme() + * sets up for the real application. + * + * Skip either one and every lookup below quietly takes the "unknown key" + * path instead, which would pass without testing the thing it claims to. + */ +class InstanceIconProviderTest : public QObject +{ + Q_OBJECT + + private slots: + void initTestCase() + { + // Same as ThemeManager::setIconTheme(), restated rather than called + // so this measures IconList/Qt, not that wrapper. ":/icons" goes + // first so the bundled theme wins over a same-named one on the host. + QStringList searchPaths = QIcon::themeSearchPaths(); + searchPaths.prepend(QStringLiteral(":/icons")); + QIcon::setThemeSearchPaths(searchPaths); + QIcon::setThemeName(QStringLiteral("multimc")); + } + + void cleanupTestCase() + { + QIcon::setThemeName(QString()); + } + + void init() + { + m_tempDir = std::make_unique(); + QVERIFY(m_tempDir->isValid()); + m_icons = std::make_shared(kBuiltinIconDirs, m_tempDir->path()); + } + + void cleanup() + { + m_icons.reset(); + m_tempDir.reset(); + } + + void knownBuiltinKeyReturnsRequestedSize() + { + InstanceIconProvider provider(m_icons); + + QSize size; + const QPixmap pixmap = provider.requestPixmap( + QStringLiteral("grass"), &size, QSize(64, 64)); + + QVERIFY(!pixmap.isNull()); + QCOMPARE(size, QSize(64, 64)); + QCOMPARE(pixmap.size(), QSize(64, 64)); + } + + void revisionQueryIsIgnored() + { + InstanceIconProvider provider(m_icons); + + QSize plainSize; + QSize revisionedSize; + const QPixmap plain = provider.requestPixmap( + QStringLiteral("grass"), &plainSize, QSize(48, 48)); + const QPixmap revisioned = provider.requestPixmap( + QStringLiteral("grass?rev=7"), &revisionedSize, QSize(48, 48)); + + QCOMPARE(revisionedSize, plainSize); + QCOMPARE(revisioned.toImage(), plain.toImage()); + } + + void unknownKeyFallsBackToNonNullPixmap() + { + InstanceIconProvider provider(m_icons); + + QSize size; + const QPixmap pixmap = provider.requestPixmap( + QStringLiteral("this-key-does-not-exist"), &size, QSize(32, 32)); + + QVERIFY(!pixmap.isNull()); + QCOMPARE(size, QSize(32, 32)); + } + + void invalidRequestedSizeUsesDefault() + { + InstanceIconProvider provider(m_icons); + + QSize size; + const QPixmap pixmap = provider.requestPixmap( + QStringLiteral("grass"), &size, QSize()); + + QVERIFY(!pixmap.isNull()); + QCOMPARE(size, QSize(kDefaultIconExtent, kDefaultIconExtent)); + } + + private: + std::unique_ptr m_tempDir; + std::shared_ptr m_icons; +}; + +int main(int argc, char* argv[]) +{ + /* Same reasoning as QmlModule_test.cpp: a Pixmap-type QQuickImageProvider + * needs a QGuiApplication, and forcing offscreen keeps this runnable on a + * headless runner without depending on the harness to set + * QT_QPA_PLATFORM for us. */ + qputenv("QT_QPA_PLATFORM", "offscreen"); + + QGuiApplication app(argc, argv); + + /* The compiled "multimc" resource sits in MeshMC_logic, a static + * library; without a symbol reference into it, the linker drops the + * object that registers it, and every builtinPaths lookup below would + * silently come back empty. main.cpp does the same for the launcher + * itself. */ + Q_INIT_RESOURCE(multimc); + + InstanceIconProviderTest test; + return QTest::qExec(&test, argc, argv); +} + +#include "InstanceIconProvider_test.moc" diff --git a/launcher/qml/Main.qml b/launcher/qml/Main.qml index b2dde40c..1f304a1c 100644 --- a/launcher/qml/Main.qml +++ b/launcher/qml/Main.qml @@ -3,22 +3,658 @@ // SPDX-License-Identifier: Apache-2.0 import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import MeshMC.Theme +import MeshMC.Components -/* - * Placeholder root of the QML user interface. - * - * At this stage the launcher still runs the QtWidgets MainWindow; this exists - * so that the QML module, its resource prefix and the qmlcachegen step are - * built and covered by a test from the first commit onwards, rather than - * appearing all at once later. QmlModule_test instantiates it headlessly. - */ -Item { +ApplicationWindow { id: root - // Read by QmlModule_test to prove the component really instantiated - // rather than silently resolving to a default-constructed Item. + required property var instanceModel + required property var selection + // QmlShell: actions (launchInstance, editInstance, ...), account summary + // and the extra instance models the library needs. + required property var shell + readonly property string moduleName: "MeshMC" - implicitWidth: 960 - implicitHeight: 600 + width: 1240 + height: 780 + minimumWidth: 860 + minimumHeight: 540 + title: "MeshMC" + color: Theme.palette.canvas + + // Whether launcher/qml/Cat was built at all (MESHMC_HAS_CAT): the QML + // shell has no other way to tell a Quick3D-less build apart from one + // where the cat is merely turned off, and Settings needs that + // distinction to hide the cat's rows instead of offering a switch that + // would do nothing. Set from QmlShell::rootProperties(); false is the + // right default for QmlModule_test, which never sets it. + property bool catAvailable: false + + property string selectedId: "" + // "home", "library", "discover", "settings", "instance" or "accounts". + property string page: "home" + // The instance the instance page shows. + property string openedInstanceId: "" + property var openedDetails: null + + function openInstance(id) { + if (!root.shell || typeof root.shell.instanceDetails !== "function") + return + root.openedDetails = root.shell.instanceDetails(id) + root.openedInstanceId = id + root.selectedId = id + root.page = "instance" + } + + Component.onCompleted: { + if (root.shell && root.shell.settings) { + SettingsStore.adapter = root.shell.settings + var mode = SettingsStore.string("UiThemeMode") + if (mode === "dark" || mode === "light") + Theme.mode = mode + var scheme = SettingsStore.string("UiPalette") + if (scheme === "amethyst" || scheme === "ember" || scheme === "diamond" || scheme === "grass") + Theme.scheme = scheme + } + onboarding.start() + if (root.devRoute.length > 0) + Qt.callLater(root.applyDevRoute) + } + + // Startup route for review snapshots (MESHMC_QML_ROUTE): ";"-separated + // steps such as "theme=light;scheme=ember;size=1400x900;page=settings;section=java", + // "instance=;tab=mods", "page=discover;detail=0", "newinstance", + // "gallery" or "page=home;crashed=" (marks that instance as having + // crashed last time, for the Home page's chip -- see QmlShell's + // debugMarkInstanceCrashed()). + property string devRoute: "" + function applyDevRoute() { + var steps = root.devRoute.split(";") + for (var i = 0; i < steps.length; ++i) { + var eq = steps[i].indexOf("=") + var key = (eq < 0 ? steps[i] : steps[i].slice(0, eq)).trim() + var value = eq < 0 ? "" : steps[i].slice(eq + 1).trim() + if (key === "theme") + Theme.mode = value + else if (key === "scheme") + Theme.scheme = value + else if (key === "size") { + var wh = value.split("x") + root.width = parseInt(wh[0]) + root.height = parseInt(wh[1]) + } else if (key === "page") + root.page = value + else if (key === "instance") + root.openInstance(value) + else if (key === "tab") + instancePage.tab = value + else if (key === "section") + settingsPage.section = value + else if (key === "select") + root.selectedId = value + else if (key === "detail") + devDetail.start() + else if (key === "newinstance") + root.openNewInstance(value) + else if (key === "gallery") + galleryLoader.active = true + else if (key === "profilemenu") + Qt.callLater(() => topBar.openProfileMenu()) + else if (key === "picker") + Qt.callLater(() => playDock.openPicker()) + else if (key === "sidebar") + SettingsStore.setValue("UiSidebarCollapsed", value === "collapsed") + else if (key === "crashed") + root.call("debugMarkInstanceCrashed", value) + // Deterministic cat pose for review snapshots -- see + // CatOverlay.qml's own `demo` property. + else if (key === "cat" && catLoader.item) + catLoader.item.demo = value + } + } + Timer { + id: devDetail + interval: 3500 + onTriggered: { + var parts = root.devRoute.match(/detail=(\d+)/) + discoverPage.openResult(parts ? parseInt(parts[1]) : 0) + } + } + onSelectedIdChanged: { + if (selectedId.length > 0) + root.selection.selectOnly(selectedId) + else + root.selection.clear() + } + + // mode is "create" (default) or "import" -- see NewInstanceDialog.qml. + function openNewInstance(mode) { + if (!root.shell || !root.shell.newInstance) + return + newInstanceDialog.controller = root.shell.newInstance + newInstanceDialog.mode = mode === "import" ? "import" : "create" + newInstanceDialog.open() + } + + // Play, everywhere in the shell. With no account at all the classic + // launch flow would pop a widget dialog; say what is missing instead + // and go where it is fixed. + function launch(id) { + if (root.shell && root.shell.accountCount === 0) { + root.page = "accounts" + toast.show(qsTr("Sign in with the Microsoft account that owns Minecraft to play.")) + return + } + root.call("launchInstance", id) + } + + // Home/Library/Discover/Instance keep the play bar; Settings/Accounts + // hide it -- no "instance to play" on either, and the extra chrome would + // just crowd two already form-heavy pages. Home needs it too: its own + // "Jump back in" cards deliberately use a secondary Play, and the dock's + // Play is what keeps that page down to one accent-filled control. + function dockVisibleFor(page) { + return page === "home" || page === "library" || page === "discover" || page === "instance" + } + + function instanceGroups() { + var groups = root.shell && root.shell.groups ? root.shell.groups : [] + return [""].concat(groups.filter(g => g.length > 0)) + } + + function call(name, arg) { + if (root.shell && typeof root.shell[name] === "function") + arg === undefined ? root.shell[name]() : root.shell[name](arg) + } + + Shortcut { + sequences: [StandardKey.Find] + onActivated: topBar.focusSearch() + } + // The game's console, asked for by the launch flow (ShowConsole, or a + // crash): the instance page, on its Log tab. + Connections { + target: root.shell + ignoreUnknownSignals: true + function onOpenInstanceLog(id) { + root.openInstance(id) + instancePage.tab = "log" + } + } + + Binding { + target: root.shell && root.shell.instancePageModel ? root.shell.instancePageModel : null + property: "instanceId" + value: root.openedInstanceId.length > 0 ? root.openedInstanceId : "/" + when: !!root.shell && !!root.shell.instancePageModel + } + + // The play bar's instance: the selection if there is one, otherwise the + // most recently played instance, otherwise just the first one -- the + // same fallback the library's old hero card used. heroModel is free for + // this now that the hero card itself is gone (see LibraryPage.qml). + readonly property string dockInstanceId: root.selectedId.length > 0 ? root.selectedId + : (root.shell && root.shell.recentModel && root.shell.recentModel.firstId ? root.shell.recentModel.firstId + : (root.instanceModel && root.instanceModel.firstId ? root.instanceModel.firstId : "")) + + Binding { + target: root.shell && root.shell.heroModel ? root.shell.heroModel : null + property: "instanceId" + value: root.dockInstanceId + when: !!root.shell && !!root.shell.heroModel + } + + Shortcut { + sequences: [StandardKey.New] + onActivated: root.openNewInstance() + } + + RowLayout { + anchors.fill: parent + spacing: 0 + + SidebarNav { + Layout.fillHeight: true + Layout.preferredWidth: implicitWidth + // An icon rail below this saves real width for the content + // pages on the launcher's own minimum-width window, rather than + // squeezing the library grid down to one column behind it. ORed + // with the persisted manual toggle (see SidebarNav's own + // collapseToggle), so a small window still forces the rail + // regardless of what was last chosen. + collapsed: root.width < 1000 || SettingsStore.bool("UiSidebarCollapsed") + items: [ + { id: "home", icon: "home", label: qsTr("Home") }, + { id: "library", icon: "library", label: qsTr("Library") }, + { id: "discover", icon: "compass", label: qsTr("Discover") } + ] + footerItems: [ + { id: "settings", icon: "settings", label: qsTr("Settings") } + ] + currentId: root.page === "instance" ? "library" : root.page === "accounts" ? "" : root.page + recentModel: root.shell && root.shell.recentModel ? root.shell.recentModel : null + onItemActivated: (id) => root.page = id + onRecentActivated: (id) => { + root.page = "library" + root.selectedId = id + } + onRecentPlayRequested: (id) => root.launch(id) + } + + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 0 + + TopBar { + id: topBar + Layout.fillWidth: true + // The instance page has its own banner and way back. + visible: root.page !== "instance" + title: root.page === "home" ? qsTr("Home") + : root.page === "settings" ? qsTr("Settings") + : root.page === "discover" ? qsTr("Discover") + : root.page === "accounts" ? qsTr("Accounts") : qsTr("Library") + count: root.page === "library" && root.instanceModel && root.instanceModel.count !== undefined + ? root.instanceModel.count : -1 + searchVisible: root.page === "library" + searchPlaceholder: qsTr("Search instances") + onSearchTextChanged: root.instanceModel.filterText = searchText + + accountName: root.shell && root.shell.accountName ? root.shell.accountName : "" + accountKind: root.shell && root.shell.accountKind ? root.shell.accountKind : "" + accountAvatarSource: root.shell && root.shell.accountFace ? root.shell.accountFace : "" + accountsController: root.shell && root.shell.accountsController ? root.shell.accountsController : null + onOpenAccountsRequested: root.page = "accounts" + + ComboBox { + id: sortCombo + visible: root.page === "library" + implicitWidth: 152 + model: [ + { value: "Name", label: qsTr("Name") }, + { value: "LastLaunch", label: qsTr("Last played") }, + { value: "TotalTimePlayed", label: qsTr("Time played") } + ] + textRole: "label" + valueRole: "value" + // InstSortMode is the same launcher-wide setting the + // classic Settings page's "Sort instances by" choice + // writes -- QmlShell already re-sorts every instance + // proxy when it changes, so this needs no plumbing of + // its own beyond reading and writing it. Deferred with + // callLater: this control completes before root's own + // Component.onCompleted has pointed SettingsStore at a + // live adapter, so reading the setting here directly + // would always see it empty. + Component.onCompleted: Qt.callLater(() => sortCombo.currentIndex = Math.max(0, sortCombo.indexOfValue(SettingsStore.string("InstSortMode") || "Name"))) + onActivated: SettingsStore.setValue("InstSortMode", currentValue) + + Accessible.name: qsTr("Sort instances by") + } + + SegmentedControl { + visible: root.page === "library" + options: [ + { value: "grid", label: qsTr("Grid") }, + { value: "list", label: qsTr("List") } + ] + current: libraryPage.viewMode + onActivated: (value) => libraryPage.viewMode = value + } + + Button { + visible: root.page === "library" + text: qsTr("New instance") + icon.source: Icons.url("plus") + onClicked: root.openNewInstance() + } + } + + // A page change fades the outgoing page out, swaps + // StackLayout's currentIndex at the (invisible) midpoint, then + // fades the incoming one back in with a small upward slide -- + // StackLayout still flips each page's own `visible` at exactly + // that swap instant, same as a plain binding would, so + // DiscoverPage's visible-based lazy search is untouched and + // Layout sizing still comes from the StackLayout underneath. + Item { + id: pageHost + Layout.fillWidth: true + Layout.fillHeight: true + Layout.minimumWidth: 0 + + readonly property var pageOrder: ["home", "library", "settings", "discover", "instance", "accounts"] + // Whether the play bar shows for the page currently on + // screen -- updated at the same invisible midpoint as + // pageStack's own currentIndex (see pageTransition below), + // never straight from root.page, so the bar never appears or + // disappears while the old page is still visibly fading. + property bool dockVisible: root.dockVisibleFor(root.page) + + transform: Translate { id: pageSlide } + + SequentialAnimation { + id: pageTransition + property int nextIndex: 0 + NumberAnimation { target: pageHost; property: "opacity"; to: 0; duration: Theme.motion.fast; easing.type: Theme.motion.easing } + PropertyAction { target: pageStack; property: "currentIndex"; value: pageTransition.nextIndex } + PropertyAction { target: pageHost; property: "dockVisible"; value: root.dockVisibleFor(pageHost.pageOrder[pageTransition.nextIndex]) } + ParallelAnimation { + NumberAnimation { target: pageHost; property: "opacity"; to: 1; duration: Theme.motion.normal; easing.type: Theme.motion.easing } + NumberAnimation { target: pageSlide; property: "y"; from: Theme.space.sm; to: 0; duration: Theme.motion.normal; easing.type: Theme.motion.easing } + } + } + + Connections { + target: root + function onPageChanged() { + pageTransition.nextIndex = pageHost.pageOrder.indexOf(root.page) + pageTransition.restart() + } + } + + StackLayout { + id: pageStack + anchors.fill: parent + Component.onCompleted: currentIndex = pageHost.pageOrder.indexOf(root.page) + + HomePage { + id: homePage + recentModel: root.shell && root.shell.recentModel ? root.shell.recentModel : null + instanceModel: root.instanceModel + recentWorldsModel: root.shell && root.shell.recentWorlds ? root.shell.recentWorlds : null + accountName: root.shell && root.shell.accountName ? root.shell.accountName : "" + + onLaunchRequested: (id) => root.launch(id) + onStopRequested: (id) => root.call("killInstance", id) + onOpenInstanceRequested: (id) => root.openInstance(id) + onOpenInstanceWorldsRequested: (id) => { + root.openInstance(id) + instancePage.tab = "worlds" + } + onCreateRequested: root.openNewInstance() + onDiscoverRequested: root.page = "discover" + onLibraryRequested: root.page = "library" + } + + LibraryPage { + id: libraryPage + focus: true + instanceModel: root.instanceModel + sectionModelFor: function (group) { + return root.shell && typeof root.shell.sectionModel === "function" + ? root.shell.sectionModel(group) : null + } + searchText: topBar.searchText + selectedId: root.selectedId + + onSelectRequested: (id) => root.selectedId = id + onLaunchRequested: (id) => root.launch(id) + onStopRequested: (id) => root.call("killInstance", id) + onEditRequested: (id) => root.openInstance(id) + onFolderRequested: (id) => root.call("openInstanceFolder", id) + onCreateRequested: root.openNewInstance() + onRenameRequested: (id, name) => { + renameDialog.targetId = id + renameDialog.value = name + renameDialog.open() + } + onIconRequested: (id, iconKey) => { + iconDialog.targetId = id + iconDialog.current = iconKey + iconDialog.open() + } + onGroupRequested: (id, group) => { + groupDialog.targetId = id + groupDialog.value = group + groupDialog.suggestions = root.instanceGroups() + groupDialog.open() + } + onDuplicateRequested: (id, name, group) => { + duplicateDialog.targetId = id + duplicateDialog.targetGroup = group + duplicateDialog.value = qsTr("%1 (copy)").arg(name) + duplicateDialog.open() + } + onDeleteRequested: (id, name) => { + deleteDialog.targetId = id + deleteDialog.text = qsTr("Delete \u201c%1\u201d? Its folder \u2014 worlds, mods, screenshots \u2014 is removed for good.").arg(name) + deleteDialog.open() + } + onClearSearchRequested: topBar.searchText = "" + } + + SettingsPage { + id: settingsPage + languages: root.shell && root.shell.languages ? root.shell.languages : null + selectLanguage: function (key) { root.shell.selectLanguage(key) } + pluginSurfaces: root.shell && typeof root.shell.pluginSurfaces === "function" + ? root.shell.pluginSurfaces(0, "") : null + systemMemoryMiB: root.shell && root.shell.systemMemoryMiB ? root.shell.systemMemoryMiB : 8192 + // Whether to show the Cat group at all -- see + // root.catAvailable's own comment. + catAvailable: root.catAvailable + onOpenClassicRequested: (page) => { + if (page === "accounts") + root.page = "accounts" + else + root.call("openSettings", page) + } + onOpenPathRequested: (path) => root.call("openPath", path) + } + + DiscoverPage { + id: discoverPage + model: root.shell && root.shell.modpackModel ? root.shell.modpackModel : null + installer: function (projectId, versionId, name, group) { + return root.shell.installModpack(projectId, versionId, name, group) + } + onShowInstanceRequested: (id) => { + root.page = "library" + if (id.length > 0) + root.selectedId = id + } + onOtherPlatformsRequested: root.openNewInstance("import") + } + + InstancePage { + id: instancePage + headerModel: root.shell && root.shell.instancePageModel ? root.shell.instancePageModel : null + details: root.openedDetails + systemMemoryMiB: root.shell && root.shell.systemMemoryMiB ? root.shell.systemMemoryMiB : 8192 + accountName: root.shell && root.shell.accountName ? root.shell.accountName : "" + accountKind: root.shell && root.shell.accountKind ? root.shell.accountKind : "" + accountAvatarSource: root.shell && root.shell.accountFace ? root.shell.accountFace : "" + accountsController: root.shell && root.shell.accountsController ? root.shell.accountsController : null + onOpenAccountsRequested: root.page = "accounts" + onBackRequested: root.page = "library" + onClassicEditorRequested: (id) => root.call("editInstance", id) + // Two arguments -- root.call() only ever forwards + // one -- so this goes straight to the shell instead. + onJoinServerRequested: (id, address) => { if (root.shell) root.shell.joinServer(id, address) } + pluginSurfacesFor: function (anchor, instanceId) { + return root.shell && typeof root.shell.pluginSurfaces === "function" + ? root.shell.pluginSurfaces(anchor, instanceId) : null + } + contentInstaller: function (row, versionId) { + return root.shell.installContent(row, versionId) + } + onOpenPathRequested: (path) => root.call("openPath", path) + } + + AccountsPage { + id: accountsPage + controller: root.shell && root.shell.accountsController ? root.shell.accountsController : null + } + } + + // The roaming 3D cat (launcher/qml/Cat/CatOverlay.qml), over + // whichever page is showing. `active` only turns true when + // the module actually exists (catAvailable, MESHMC_HAS_CAT) + // and the setting is on, so a build without Qt Quick3D never + // even resolves the qrc path below -- see the CMake option + // MeshMC_ENABLE_CAT this all hangs off. Filling pageHost + // rather than the whole window is what keeps its own + // walkArea (CatOverlay.qml's default, unset here) right of + // the sidebar, below the header and above the play bar + // without this file needing to know any of their sizes. + Loader { + id: catLoader + anchors.fill: parent + active: root.catAvailable && SettingsStore.bool("CatEnabled") + source: active ? "qrc:/qt/qml/MeshMC/Cat/CatOverlay.qml" : "" + } + } + + // A genuine row below the page area, not a floating overlay: the + // StackLayout above shrinks by exactly this bar's height + // whenever it is visible, so it can never cover a page's + // content -- including Discover's, whose QML this change does + // not own. visible follows pageHost.dockVisible rather than + // root.page directly, so it appears/disappears at the same + // invisible mid-fade instant the page itself swaps at, instead + // of jumping the layout while the outgoing page is still + // visible. + PlayDock { + id: playDock + Layout.fillWidth: true + visible: pageHost.dockVisible + rowModel: root.shell && root.shell.heroModel ? root.shell.heroModel : null + instanceModel: root.instanceModel + recentModel: root.shell && root.shell.recentModel ? root.shell.recentModel : null + selectedId: root.selectedId + onSelectRequested: (id) => root.selectedId = id + onLaunchRequested: (id) => root.launch(id) + onStopRequested: (id) => root.call("killInstance", id) + onCancelRequested: (id) => root.call("killInstance", id) + } + } + } + + NewInstanceDialog { + id: newInstanceDialog + iconsModel: root.shell && root.shell.iconsModel ? root.shell.iconsModel : null + onCreated: root.page = "library" + } + + // Instance management, from the library's menu. + PromptDialog { + id: renameDialog + property string targetId + title: qsTr("Rename instance") + confirmText: qsTr("Rename") + onSubmitted: (text) => { + if (root.shell.renameInstance(targetId, text)) + close() + else + error = qsTr("That name cannot be used.") + } + } + + PromptDialog { + id: groupDialog + property string targetId + title: qsTr("Move to group") + label: qsTr("Type a new group or pick an existing one.") + placeholder: qsTr("No group") + confirmText: qsTr("Move") + allowEmpty: true + onSubmitted: (text) => { + root.shell.setInstanceGroup(targetId, text) + close() + } + } + + PromptDialog { + id: duplicateDialog + property string targetId + property string targetGroup + property var watcher: null + title: qsTr("Duplicate instance") + label: qsTr("A full copy, worlds included, under a new name.") + confirmText: qsTr("Duplicate") + onSubmitted: (text) => { + watcher = root.shell.duplicateInstance(targetId, text, targetGroup) + close() + if (watcher) + toast.show(qsTr("Copying \u201c%1\u201d\u2026").arg(text)) + else + toast.show(qsTr("The instance could not be copied."), "danger") + } + Connections { + target: duplicateDialog.watcher + ignoreUnknownSignals: true + function onFinished(ok) { + toast.show(ok ? qsTr("Copy ready in your library.") + : qsTr("Copy failed: %1").arg(duplicateDialog.watcher.error), + ok ? "success" : "danger") + } + } + } + + ConfirmDialog { + id: deleteDialog + property string targetId + title: qsTr("Delete instance") + confirmText: qsTr("Delete instance") + onConfirmed: { + if (root.shell.deleteInstance(targetId)) { + if (root.selectedId === targetId) + root.selectedId = "" + toast.show(qsTr("Instance deleted."), "success") + } else { + toast.show(qsTr("The instance could not be deleted. Is it running?"), "danger") + } + } + } + + IconPickerDialog { + id: iconDialog + property string targetId + iconsModel: root.shell && root.shell.iconsModel ? root.shell.iconsModel : null + onPicked: (key) => root.shell.setInstanceIcon(targetId, key) + onOpenFolderRequested: root.call("openPath", root.shell.iconsDir || "icons") + } + + Toast { + id: toast + } + + // Questions the core asks while it works, and its "please wait". + UiRequestDialog { + id: uiRequests + host: root.shell && root.shell.uiHost ? root.shell.uiHost : null + // Until this exists, the core keeps asking through the classic + // dialogs rather than waiting on a question nobody can see. + Component.onCompleted: if (host) host.setPresenterReady(true) + Component.onDestruction: if (host) host.setPresenterReady(false) + } + + BusyOverlay { + busy: root.shell && root.shell.uiHost ? root.shell.uiHost.busy : false + text: root.shell && root.shell.uiHost ? root.shell.uiHost.busyText : "" + } + + // First run: language, Java, account -- over everything else. + OnboardingView { + id: onboarding + shell: root.shell + onSignInRequested: { + root.page = "accounts" + accountsPage.startMicrosoftLogin() + } + } + + Loader { + id: galleryLoader + anchors.fill: parent + z: 100 + active: false + sourceComponent: Gallery {} + } } diff --git a/launcher/qml/QmlModule_test.cpp b/launcher/qml/QmlModule_test.cpp index f46b3671..147fc9a7 100644 --- a/launcher/qml/QmlModule_test.cpp +++ b/launcher/qml/QmlModule_test.cpp @@ -20,7 +20,9 @@ #include #include #include +#include #include +#include #include #include @@ -45,6 +47,15 @@ class QmlModuleTest : public QObject Q_OBJECT private slots: + /* The same controls style QmlShell::show() selects: components use its + * extra properties (Button.danger, ...), which Qt's default style lacks, + * so without it Main.qml would not load here although it does in the + * application. */ + void initTestCase() + { + QQuickStyle::setStyle(QStringLiteral("MeshMC.Style")); + } + void rootComponentInstantiates() { QQmlEngine engine; @@ -58,7 +69,22 @@ class QmlModuleTest : public QObject qPrintable(QStringLiteral("component not ready: %1") .arg(component.errorString()))); - std::unique_ptr root(component.create()); + /* The root requires the instance model, the selection and the shell; + * an empty stand-in is enough to prove the component loads, and a + * required property left unset would itself be a load error worth + * catching here. The shell is a plain QObject: this test only proves + * Main.qml accepts something for the property, not what QmlShell + * itself does. */ + QStandardItemModel instances; + QObject selection; + QObject shell; + std::unique_ptr root(component.createWithInitialProperties( + {{QStringLiteral("instanceModel"), + QVariant::fromValue(&instances)}, + {QStringLiteral("selection"), + QVariant::fromValue(&selection)}, + {QStringLiteral("shell"), + QVariant::fromValue(&shell)}})); QVERIFY2(root != nullptr, qPrintable(component.errorString())); /* Guards against the component resolving to something default diff --git a/launcher/qml/QmlShell.cpp b/launcher/qml/QmlShell.cpp new file mode 100644 index 00000000..b2ead671 --- /dev/null +++ b/launcher/qml/QmlShell.cpp @@ -0,0 +1,829 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "qml/QmlShell.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "BaseInstance.h" +#include "FileSystem.h" +#include "InstanceCopyTask.h" +#include "InstanceList.h" +#include "icons/IconList.h" +#include "java/JavaInstallList.h" +#include "models/AccountsController.h" +#include "models/IdSelectionModel.h" +#include "models/InstanceDetails.h" +#include "models/InstanceFilterModel.h" +#include "models/NewInstanceController.h" +#include "models/RecentWorldsModel.h" +#include "models/SettingsAdapter.h" +#include "models/ContentBrowser.h" +#include "modplatform/modrinth/ModrinthModpackModel.h" +#include "tasks/Task.h" +#include "tasks/TaskWatcher.h" +#include "translations/TranslationsModel.h" +#include "Sys.h" +#include "DesktopServices.h" +#include "settings/SettingsObject.h" +#include +#include "qml/AccountFaceProvider.h" +#include "qml/InstanceIconProvider.h" +#include "qml/QmlUiHost.h" +#include "qml/ScreenshotThumbnailProvider.h" +#include "core/LauncherContext.h" +#include "minecraft/auth/AccountList.h" +#include "minecraft/auth/MinecraftAccount.h" + +namespace +{ + /* Spelled out rather than loadFromModule(), which is Qt 6.5+; the floor is + * 6.4. The module sets RESOURCE_PREFIX "/qt/qml" so this path is stable. */ + const QUrl kRootUrl(QStringLiteral("qrc:/qt/qml/MeshMC/Main.qml")); + + /* Function-local static rather than a plain namespace-scope QmlShell + * member: this is a process-wide seam Application installs once, not + * per-instance state, and a Meyer's singleton sidesteps static + * initialisation order between translation units. */ + QmlShell::PluginSurfaceFactory& pluginSurfaceFactory() + { + static QmlShell::PluginSurfaceFactory factory; + return factory; + } +} // namespace + +QString sanitizedInstanceName(const QString& name) +{ + QString sanitized = name; + // Same as NoReturnTextEdit's commit path (InstanceDelegate.cpp): a + // pasted multi-line name becomes one line rather than being rejected. + sanitized.replace(QLatin1Char('\n'), QLatin1Char(' ')); + return sanitized.trimmed(); +} + +bool languageSetupStepNeeded(const QString& language) +{ + return language.isEmpty(); +} + +bool javaSetupStepNeeded(bool hostnameChanged, bool javaPathResolves) +{ + return hostnameChanged || !javaPathResolves; +} + +QmlShell::QmlShell(QObject* parent) : QObject(parent) +{ + /* Sidebar account summary. Both signals exist on AccountList already; + * either one moving the default account or the list itself is reason + * enough to re-read all three properties, so both are wired to the + * same accountChanged() rather than tracked separately. */ + auto accounts = LAUNCHER->accounts(); + /* Any change may be a new skin under the same account id, and QML + * caches images by url: the revision in the face url makes it refetch. */ + const auto bump = [this] { + ++m_accountRevision; + emit accountChanged(); + }; + connect(accounts.get(), &AccountList::listChanged, this, bump); + connect(accounts.get(), &AccountList::defaultAccountChanged, this, bump); + + // Once per run, like Application::createSetupWizard() used to compute + // this once before deciding whether to show the widget wizard. + recomputeSetupSteps(); +} + +QmlShell::~QmlShell() = default; + +QObject* QmlShell::expose(QObject* object) +{ + if (object) { + QQmlEngine::setObjectOwnership(object, QQmlEngine::CppOwnership); + } + return object; +} + +void QmlShell::setPluginSurfaceFactory(PluginSurfaceFactory factory) +{ + pluginSurfaceFactory() = std::move(factory); +} + +QObject* QmlShell::pluginSurfaces(int anchor, const QString& anchorContext) +{ + const auto key = std::make_pair(anchor, anchorContext); + auto it = m_pluginSurfaceModels.find(key); + if (it != m_pluginSurfaceModels.end()) { + return expose(it->second.get()); + } + + auto& factory = pluginSurfaceFactory(); + if (!factory) { + return nullptr; + } + QObject* model = factory(anchor, anchorContext); + if (!model) { + return nullptr; + } + m_pluginSurfaceModels.emplace(key, std::unique_ptr(model)); + return expose(model); +} + +QString QmlShell::accountName() const +{ + auto account = LAUNCHER->accounts()->defaultAccount(); + return account ? account->profileName() : QString(); +} + +QString QmlShell::accountFace() const +{ + auto account = LAUNCHER->accounts()->defaultAccount(); + if (!account) { + return QString(); + } + // Offline accounts have no profile id; the provider accepts either. + const QString id = account->profileId().isEmpty() ? account->internalId() + : account->profileId(); + return QStringLiteral("image://accountface/%1?rev=%2") + .arg(id) + .arg(m_accountRevision); +} + +QString QmlShell::accountKind() const +{ + auto account = LAUNCHER->accounts()->defaultAccount(); + if (!account) { + return QString(); + } + return account->isMSA() ? QStringLiteral("Microsoft") + : QStringLiteral("Offline"); +} + +int QmlShell::accountCount() const +{ + return LAUNCHER->accounts()->count(); +} + +void QmlShell::launchInstance(const QString& id) +{ + emit launchRequested(id); +} + +void QmlShell::killInstance(const QString& id) +{ + emit killRequested(id); +} + +void QmlShell::editInstance(const QString& id) +{ + emit editRequested(id); +} + +void QmlShell::openInstanceFolder(const QString& id) +{ + emit folderRequested(id); +} + +void QmlShell::joinServer(const QString& id, const QString& address) +{ + emit joinServerRequested(id, address); +} + +void QmlShell::createInstance() +{ + emit createInstanceRequested(); +} + +void QmlShell::openSettings(const QString& page) +{ + emit settingsRequested(page); +} + +void QmlShell::openPath(const QString& path) +{ + DesktopServices::openDirectory(QDir(path).absolutePath(), true); +} + +void QmlShell::manageAccounts() +{ + emit accountsRequested(); +} + +void QmlShell::showInstanceLogRequested(const QString& id) +{ + emit openInstanceLog(id); +} + +bool QmlShell::renameInstance(const QString& id, const QString& name) +{ + auto instance = LAUNCHER->instances()->getInstanceById(id); + if (!instance) { + return false; + } + + const QString sanitized = sanitizedInstanceName(name); + if (sanitized.isEmpty()) { + return false; + } + + // FIXME: if no change, do not set. setting involves saving a file. + // (Same shortcut InstanceList::setData() takes; BaseInstance::setName() + // does not bother checking on its own.) + if (instance->name() != sanitized) { + instance->setName(sanitized); + } + return true; +} + +void QmlShell::setInstanceGroup(const QString& id, const QString& group) +{ + LAUNCHER->instances()->setInstanceGroup(id, group); +} + +void QmlShell::setInstanceIcon(const QString& id, const QString& iconKey) +{ + auto instance = LAUNCHER->instances()->getInstanceById(id); + if (!instance) { + return; + } + instance->setIconKey(iconKey); +} + +void QmlShell::debugMarkInstanceCrashed(const QString& id) +{ + auto instance = LAUNCHER->instances()->getInstanceById(id); + if (!instance) { + return; + } + instance->setCrashed(true); +} + +bool QmlShell::importIcon(const QString& fileUrlOrPath) +{ + const QUrl url(fileUrlOrPath); + const QString path = url.isLocalFile() ? url.toLocalFile() : fileUrlOrPath; + + const QFileInfo info(path); + if (!info.isReadable() || !info.isFile()) { + return false; + } + + auto icons = LAUNCHER->icons(); + icons->installIcons({ path }); + + /* Same key IconList itself derives for a dropped/installed file (see + * IconList::directoryChanged()) - deterministic from the file name, so + * this does not need to wait for the watcher to actually pick the copy + * up before telling the caller what to expect. installIcons() silently + * no-ops on a rejected extension or a same-key collision, same as + * IconPickerDialog's own "Add Icon" button, so this is optimistic + * rather than a confirmation the icon list now has it. */ + emit iconImported(info.baseName()); + return true; +} + +QObject* QmlShell::duplicateInstance(const QString& id, const QString& newName, + const QString& group) +{ + auto original = LAUNCHER->instances()->getInstanceById(id); + if (!original) { + return nullptr; + } + + const QString name = sanitizedInstanceName(newName); + if (name.isEmpty()) { + return nullptr; + } + + // Same defaults CopyInstanceDialog's checkboxes start with, and the + // same icon it starts the icon button showing (the original's). + auto* copyTask = new InstanceCopyTask(original, /* copySaves */ true, + /* keepPlaytime */ true); + copyTask->setName(name); + copyTask->setGroup(group); + copyTask->setIcon(original->iconKey()); + + Task* wrapped = LAUNCHER->instances()->wrapInstanceTask(copyTask); + auto* watcher = new TaskWatcher(Task::Ptr(wrapped), this); + watcher->setTitle(name); + wrapped->start(); + return expose(watcher); +} + +bool QmlShell::deleteInstance(const QString& id) +{ + auto instance = LAUNCHER->instances()->getInstanceById(id); + if (!instance) { + return false; + } + // Trashing a folder Java still has open fails on Windows, and the + // fallback below would then start deleting a running game's files one + // by one -- same guard MainWindow::on_actionDeleteInstance_triggered() + // has before it ever gets to the confirmation dialog. + if (instance->isRunning()) { + return false; + } + + auto instances = LAUNCHER->instances(); + if (!instances->trashInstance(id)) { + instances->deleteInstance(id); + } + + /* The widget grid persists its selection across restarts under this + * key; clearing it here too means it never restarts pointed at an + * instance that no longer exists, whichever UI is in use next time. */ + LAUNCHER->settings()->set("SelectedInstance", QString()); + return true; +} + +bool QmlShell::isInstanceRunning(const QString& id) const +{ + auto instance = LAUNCHER->instances()->getInstanceById(id); + return instance && instance->isRunning(); +} + +QStringList QmlShell::groups() const +{ + return m_instances ? m_instances->groups() : QStringList(); +} + +QObject* QmlShell::iconsModel() const +{ + return expose(LAUNCHER->icons().get()); +} + +QString QmlShell::iconsDir() const +{ + return LAUNCHER->icons()->getDirectory(); +} + +QStringList QmlShell::setupSteps() const +{ + return m_setupSteps; +} + +void QmlShell::recomputeSetupSteps() +{ + auto settings = LAUNCHER->settings(); + + // Same hostname check as Application::createSetupWizard(): a machine + // change may mean the recorded JavaPath no longer applies, so this is + // re-armed by recording the new hostname once it is seen. + const QString currentHostName = QHostInfo::localHostName(); + const QString oldHostName = settings->get("LastHostname").toString(); + const bool hostnameChanged = currentHostName != oldHostName; + if (hostnameChanged) { + settings->set("LastHostname", currentHostName); + } + const QString javaPath = settings->get("JavaPath").toString(); + const bool javaPathResolves = !FS::ResolveExecutable(javaPath).isNull(); + + QStringList steps; + if (languageSetupStepNeeded(settings->get("Language").toString())) { + steps << QStringLiteral("language"); + } + if (javaSetupStepNeeded(hostnameChanged, javaPathResolves)) { + steps << QStringLiteral("java"); + } + + if (steps == m_setupSteps) { + return; + } + m_setupSteps = steps; + qDebug() << "QML shell: setup steps needed:" << m_setupSteps; + emit setupStepsChanged(); +} + +void QmlShell::finishSetupStep(const QString& id) +{ + Q_UNUSED(id); + recomputeSetupSteps(); +} + +QObject* QmlShell::languages() const +{ + return expose(LAUNCHER->translations().get()); +} + +void QmlShell::selectLanguage(const QString& key) +{ + auto translations = LAUNCHER->translations(); + translations->selectLanguage(key); + translations->updateLanguage(key); + // selectedLanguage() rather than echoing back @p key: selectLanguage() + // falls back to the default language for an unrecognised key, and this + // should persist whatever it actually settled on, the way + // LanguageWizardPage::validatePage() persists the tree view's current + // selection rather than trusting an arbitrary string. + LAUNCHER->settings()->set("Language", translations->selectedLanguage()); + if (m_engine) { + m_engine->retranslate(); + } +} + +QObject* QmlShell::javaInstalls() const +{ + return expose(LAUNCHER->javalist().get()); +} + +bool QmlShell::javaDetecting() const +{ + return m_javaDetecting; +} + +void QmlShell::detectJava() +{ + auto task = LAUNCHER->javalist()->getLoadTask(); + if (!task) { + return; + } + if (!m_javaDetecting) { + m_javaDetecting = true; + emit javaDetectingChanged(); + } + connect(task.get(), &Task::finished, this, [this]() { + m_javaDetecting = false; + emit javaDetectingChanged(); + }); + if (!task->isRunning()) { + task->start(); + } +} + +void QmlShell::useJava(const QString& path) +{ + LAUNCHER->settings()->set("JavaPath", path); +} + +QObject* QmlShell::settings() const +{ + return expose(m_settings.get()); +} + +void QmlShell::applyProxySettings() +{ + // Same five settings, read the same way, as + // Application::initSubsystems()'s own proxy setup and the widget + // ProxyPage::applySettings() -- only the destination differs + // (LauncherContext rather than calling updateProxySettings() directly, + // since QmlShell cannot see Application from MeshMC_qml). + auto settings = LAUNCHER->settings(); + const QString proxyTypeStr = settings->get("ProxyType").toString(); + const QString addr = settings->get("ProxyAddr").toString(); + const int port = settings->get("ProxyPort").value(); + const QString user = settings->get("ProxyUser").toString(); + const QString pass = settings->get("ProxyPass").toString(); + LAUNCHER->updateProxySettings(proxyTypeStr, addr, port, user, pass); +} + +QObject* QmlShell::uiHost() const +{ + return expose(m_uiHost.get()); +} + +UiHost* QmlShell::uiHostInterface() const +{ + // Not ready until some QML item has called setPresenterReady(true) -- + // see QmlUiHost's class comment. Application::uiHost() falls back to + // the widget host while this is null, so a call reached before then + // (an automatic startup update check finding no updater binary, say) + // gets a real dialog instead of hanging on a request nothing shows. + if (m_uiHost && m_uiHost->presenterReady()) { + return m_uiHost.get(); + } + return nullptr; +} + +int QmlShell::systemMemoryMiB() const +{ + return static_cast(Sys::getSystemRam() / Sys::mebibyte); +} + +QObject* QmlShell::modpackModel() const +{ + return expose(m_modpacks.get()); +} + +QObject* QmlShell::installModpack(const QString& projectId, + const QString& versionId, + const QString& instanceName, + const QString& group) +{ + if (!m_modpacks) { + return nullptr; + } + return expose( + m_modpacks->install(projectId, versionId, instanceName, group)); +} + +QObject* QmlShell::installContent(int row, const QString& versionId) +{ + auto* browser = m_instanceDetails + ? qobject_cast( + m_instanceDetails->contentBrowser()) + : nullptr; + if (!browser) { + return nullptr; + } + return expose(browser->install(row, versionId)); +} + +QObject* QmlShell::recentModel() const +{ + return expose(m_recent.get()); +} + +QObject* QmlShell::heroModel() const +{ + return expose(m_hero.get()); +} + +QObject* QmlShell::instancePageModel() const +{ + return expose(m_instancePage.get()); +} + +QObject* QmlShell::recentWorlds() const +{ + return expose(m_recentWorlds.get()); +} + +QObject* QmlShell::accountsController() const +{ + return expose(m_accountsController.get()); +} + +QObject* QmlShell::newInstance() const +{ + /* Made on first use, not in show(): the controller starts loading the + * Minecraft version list, and opening the launcher should not fetch + * metadata nobody asked for. */ + if (!m_newInstance && m_engine) { + m_newInstance = std::make_unique(); + } + // The version list proxies it hands out need the same CppOwnership + // pinning as the controller itself, or the engine will try to delete + // them out from under it the first time QML touches one. + if (m_newInstance) { + expose(m_newInstance->minecraftVersions()); + expose(m_newInstance->loaderVersions()); + } + return expose(m_newInstance.get()); +} + +QObject* QmlShell::sectionModel(const QString& group) +{ + if (!m_instances) { + return nullptr; + } + auto& section = m_sections[group]; + if (!section) { + section = std::make_unique(); + section->setExactGroup(true); + section->setGroup(group); + section->setSourceModel(m_instances.get()); + } + return expose(section.get()); +} + +QObject* QmlShell::instanceDetails(const QString& id) +{ + if (m_instanceDetails && m_instanceDetails->instanceId() == id) { + return expose(m_instanceDetails.get()); + } + + auto instance = LAUNCHER->instances()->getInstanceById(id); + if (!instance) { + return nullptr; + } + + // Replaces (and destroys, via unique_ptr assignment) whichever detail + // page was open before - only one is kept at a time. + m_instanceDetails = std::make_unique(instance); + + // Every QObject* the bridge hands to QML needs the same CppOwnership + // pinning as everything else exposed here, or the engine will try to + // delete a model the instance still owns. + expose(m_instanceDetails->settings()); + expose(m_instanceDetails->mods()); + expose(m_instanceDetails->resourcePacks()); + expose(m_instanceDetails->shaderPacks()); + expose(m_instanceDetails->texturePacks()); + expose(m_instanceDetails->worlds()); + expose(m_instanceDetails->log()); + expose(m_instanceDetails->otherLogs()); + expose(m_instanceDetails->components()); + expose(m_instanceDetails->screenshots()); + expose(m_instanceDetails->gameOptions()); + // contentBrowser()'s own `results` is reachable straight off the + // pinned browser below without a separate expose() here: every + // ContentProviderModel it hands out is parented to it (see + // ContentBrowser::ensureModel()), and a parented QObject already keeps + // its C++ ownership once QML touches it, pin or no pin. + expose(m_instanceDetails->contentBrowser()); + // Same reasoning for loaderInstaller()'s own `versions` proxy - see + // LoaderInstaller's constructor comment. + expose(m_instanceDetails->loaderInstaller()); + + return expose(m_instanceDetails.get()); +} + +QVariantMap QmlShell::rootProperties() +{ + QVariantMap props; + props.insert(QStringLiteral("instanceModel"), + QVariant::fromValue(expose(m_instances.get()))); + props.insert(QStringLiteral("selection"), + QVariant::fromValue(expose(m_selection.get()))); + props.insert(QStringLiteral("shell"), QVariant::fromValue(expose(this))); + /* MESHMC_QML_ROUTE opens a given screen at startup -- see + * Main.qml's applyDevRoute(). With MESHMC_QML_SNAPSHOT it lets a + * review script picture any page without editing the QML. */ + props.insert(QStringLiteral("devRoute"), + qEnvironmentVariable("MESHMC_QML_ROUTE")); + /* Whether launcher/qml/Cat (the roaming cat companion) was built at + * all -- a compile-time fact, not a setting, so it is a plain bool + * handed over once here rather than a Q_PROPERTY. Main.qml's catLoader + * and SettingsPage's Cat group both gate on this: MESHMC_HAS_CAT is + * only ever defined when launcher/qml/CMakeLists.txt actually linked + * MeshMC_qml_cat in (see its MeshMC_ENABLE_CAT guard), which itself + * only happens when Qt Quick3D was found. */ +#ifdef MESHMC_HAS_CAT + props.insert(QStringLiteral("catAvailable"), true); +#else + props.insert(QStringLiteral("catAvailable"), false); +#endif + return props; +} + +bool QmlShell::show(bool minimized) +{ + if (m_window) { + m_window->showNormal(); + m_window->raise(); + m_window->requestActivate(); + return true; + } + + /* The grid shows the core's instance list through a filtering, naturally + * sorted proxy -- the same ordering the widget grid used -- and keeps its + * selection by instance id, since rows move under the proxy. */ + m_instances = std::make_unique(); + m_instances->setSourceModel(LAUNCHER->instances().get()); + // groups() forwards to m_instances->groups(); its own signal already + // fires exactly when that list actually moves (see refreshDerived()). + connect(m_instances.get(), &InstanceFilterModel::groupsChanged, this, + &QmlShell::groupsChanged); + m_recent = std::make_unique(); + m_recent->setRecentFirst(true); + m_recent->setSourceModel(LAUNCHER->instances().get()); + m_hero = std::make_unique(); + /* Nothing matches until QML names an instance, so there is no flash of + * every instance as a hero. Ids are folder names; none contains '/'. */ + m_hero->setInstanceId(QStringLiteral("/")); + m_hero->setSourceModel(LAUNCHER->instances().get()); + m_instancePage = std::make_unique(); + m_instancePage->setInstanceId(QStringLiteral("/")); + m_instancePage->setSourceModel(LAUNCHER->instances().get()); + m_recentWorlds = + std::make_unique(LAUNCHER->instances().get()); + m_selection = std::make_unique(); + m_settings = std::make_unique(LAUNCHER->settings()); + m_uiHost = std::make_unique(); + m_modpacks = std::make_unique(); + m_accountsController = + std::make_unique(LAUNCHER->accounts()); + + /* The sort order reads InstSortMode on every comparison, but a proxy + * only compares when told to: re-sort when the setting moves. */ + connect(LAUNCHER->settings().get(), &SettingsObject::SettingChanged, this, + [this](const Setting& setting, const QVariant&) { + if (setting.id() != QLatin1String("InstSortMode")) { + return; + } + m_instances->invalidate(); + for (auto& section : m_sections) { + section.second->invalidate(); + } + }); + + /* Must be chosen before the first engine exists: Qt Quick Controls binds + * its style when QtQuick.Controls is first imported, and cannot switch + * afterwards. The style falls back to Basic for anything it does not + * define itself. */ + QQuickStyle::setStyle(QStringLiteral("MeshMC.Style")); + + m_engine = std::make_unique(); + m_engine->addImportPath(QStringLiteral("qrc:/qt/qml")); + + /* The engine takes ownership of image providers. Registered before load() + * so the first frame already has icons rather than broken images. */ + m_engine->addImageProvider(QStringLiteral("instanceicon"), + new InstanceIconProvider(LAUNCHER->icons())); + m_engine->addImageProvider(QStringLiteral("accountface"), + new AccountFaceProvider()); + m_engine->addImageProvider(QStringLiteral("screenshot"), + new ScreenshotThumbnailProvider()); + m_engine->setInitialProperties(rootProperties()); + m_engine->load(kRootUrl); + + const auto roots = m_engine->rootObjects(); + m_window = roots.isEmpty() ? nullptr : qobject_cast(roots.first()); + if (!m_window) { + qCritical() << "QML shell: the root object at" << kRootUrl + << "failed to load or is not a window"; + m_engine.reset(); + return false; + } + + /* Tells whether the window is really closing, not merely hidden -- + * see eventFilter() and closed()'s doc comment. */ + m_window->installEventFilter(this); + + if (minimized) + m_window->showMinimized(); + else + m_window->show(); + + scheduleSnapshotIfRequested(); + return true; +} + +void QmlShell::scheduleSnapshotIfRequested() +{ + /* MESHMC_QML_SNAPSHOT= renders the real window, with the real + * models, into an image and exits. Combined with QT_QPA_PLATFORM=offscreen + * it never touches a display, so it can run on a CI runner and be diffed: + * this is what visual regression checks of the QML UI are built on. + * + * The delay lets layouts settle and asynchronously loaded images arrive; + * grabWindow() then forces a render of whatever is current. */ + const QString path = qEnvironmentVariable("MESHMC_QML_SNAPSHOT"); + if (path.isEmpty()) + return; + + /* Long enough for the first frame; MESHMC_QML_SNAPSHOT_DELAY (ms) waits + * longer, for pages that show network results. */ + bool delayOk = false; + const int delay = + qEnvironmentVariableIntValue("MESHMC_QML_SNAPSHOT_DELAY", &delayOk); + QTimer::singleShot(delayOk ? delay : 750, this, [this, path]() { + const QImage image = m_window->grabWindow(); + const bool saved = !image.isNull() && image.save(path); + if (saved) + qInfo() << "QML shell: snapshot written to" << path << image.size(); + else + qCritical() << "QML shell: could not write snapshot to" << path; + QCoreApplication::exit(saved ? 0 : 1); + }); +} + +QWindow* QmlShell::window() const +{ + return m_window; +} + +bool QmlShell::eventFilter(QObject* watched, QEvent* event) +{ + /* PluginManager installs its own close filter on this same window + * once a plugin registers one (main_window_install_close_filter(), + * see PluginManager::ensureCloseFilterInstalled()) -- always after + * this one, since that only happens once a plugin's + * MMCO_HOOK_UI_MAIN_READY handler runs, which is dispatched only + * after show() (and this installEventFilter() call) has already + * returned. Qt calls the most-recently-installed filter first, so a + * plugin veto (ce->ignore(), then stopping the event by returning + * true) never reaches here -- the same way a vetoed close never + * reaches MainWindow::closeEvent() on the widget path. Seeing the + * event here therefore means nothing vetoed it: the window is + * really closing, not merely being hidden (main_window_hide(), or a + * veto's own hide(), both go straight to QWindow::hide() and raise + * no QEvent::Close at all). */ + if (watched == m_window && event->type() == QEvent::Close) + emit closed(); + return QObject::eventFilter(watched, event); +} diff --git a/launcher/qml/QmlShell.h b/launcher/qml/QmlShell.h new file mode 100644 index 00000000..d7589be1 --- /dev/null +++ b/launcher/qml/QmlShell.h @@ -0,0 +1,453 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include + +#include +#include +#include +#include + +class IdSelectionModel; +class InstanceFilterModel; +class InstanceDetails; +class SettingsAdapter; +class ModrinthModpackModel; +class AccountsController; +class NewInstanceController; +class RecentWorldsModel; +class JavaInstallList; +class TranslationsModel; +class QmlUiHost; +class UiHost; +class QQmlApplicationEngine; +class QQuickWindow; +class QWindow; +class QEvent; + +/* Same trim rule the widget's inline rename editor applies before + * committing (see ui/instanceview/InstanceDelegate.cpp's setModelData()): + * embedded newlines become spaces, then the whole string is trimmed. An + * empty result means "no usable name". + * + * Free-standing rather than a QmlShell member so it can be unit-tested + * without a LauncherContext, the way NewInstanceController.h's + * composeSuggestedInstanceName() is. */ +QString sanitizedInstanceName(const QString& name); + +/* The onboarding rules Application::createSetupWizard() used to gate the + * widget SetupWizard's pages (LanguageWizardPage/JavaWizardPage) -- now + * used by QmlShell::recomputeSetupSteps() instead, since the QML shell runs + * its own onboarding rather than showing that widget on top of itself (see + * the class comment below). Free-standing for the same reason as + * sanitizedInstanceName() above: testable without a LauncherContext. */ +bool languageSetupStepNeeded(const QString& language); +/* @p hostnameChanged: the machine's hostname no longer matches the + * "LastHostname" setting recorded on a previous run -- same trigger the + * widget wizard used (a new machine, or a rename, may mean Java moved or + * vanished). @p javaPathResolves: FS::ResolveExecutable() on the "JavaPath" + * setting found something. Callers compute both against live state; this + * function only combines them, so it needs neither Qt network calls + * (QHostInfo) nor filesystem access to test. */ +bool javaSetupStepNeeded(bool hostnameChanged, bool javaPathResolves); + +/* + * The QML user interface: owns the engine, hands the core's models to it and + * loads the root window. + * + * Everything the QML side sees is passed in as a required property of the + * root window rather than set as a context property. That keeps each object's + * type visible to the QML tooling, makes a missing one a load error instead of + * a silent undefined, and leaves exactly one place -- expose() -- where + * ownership is decided. + */ +class QmlShell : public QObject +{ + Q_OBJECT + + Q_PROPERTY(QString accountName READ accountName NOTIFY accountChanged) + Q_PROPERTY(QString accountKind READ accountKind NOTIFY accountChanged) + /// Image url of the default account's skin face; empty without one. + Q_PROPERTY(QString accountFace READ accountFace NOTIFY accountChanged) + Q_PROPERTY(int accountCount READ accountCount NOTIFY accountChanged) + /// The launcher's settings, as a SettingsAdapter. + Q_PROPERTY(QObject* settings READ settings CONSTANT) + /* The core's UiHost, when it is this shell's QmlUiHost -- see + * Application::uiHost(), which prefers this over the widget host for + * as long as it is non-null. QML binds to `current`/`busy`/`busyText` + * on it (see QmlUiHost's class comment). Null until show() has run. */ + Q_PROPERTY(QObject* uiHost READ uiHost CONSTANT) + /// Installed memory in MiB: the ceiling for the memory settings. + Q_PROPERTY(int systemMemoryMiB READ systemMemoryMiB CONSTANT) + /// Modrinth modpack search for the Discover page. + Q_PROPERTY(QObject* modpackModel READ modpackModel CONSTANT) + /// Every instance, most recently played first; never-played ones left out. + Q_PROPERTY(QObject* recentModel READ recentModel CONSTANT) + /// At most one row: the instance whose id QML writes into instanceId. + Q_PROPERTY(QObject* heroModel READ heroModel CONSTANT) + /// Same, for the instance page -- separate, since the library's hero + /// keeps following its own choice underneath. + Q_PROPERTY(QObject* instancePageModel READ instancePageModel CONSTANT) + /// The Home page's "Recent worlds" row -- most recently played worlds + /// across every instance (see RecentWorldsModel's class comment). + Q_PROPERTY(QObject* recentWorlds READ recentWorlds CONSTANT) + /// Accounts page: the AccountList model plus add/remove/default/login. + Q_PROPERTY(QObject* accountsController READ accountsController CONSTANT) + /// The QML "New instance" flow: picks a Minecraft version and loader. + Q_PROPERTY(QObject* newInstance READ newInstance CONSTANT) + /* Distinct groups currently in use, for a "move to group" picker's + * suggestions -- the same list InstanceFilterModel derives for the + * library's own section headers (see m_instances), so a group picker + * never suggests something the library itself would not show. */ + Q_PROPERTY(QStringList groups READ groups NOTIFY groupsChanged) + /// The icon grid model for an icon picker; roles are `key`, `name`, + /// `isBuiltin` (see IconList::roleNames()). + Q_PROPERTY(QObject* iconsModel READ iconsModel CONSTANT) + /// Where installed icons live, for a picker's "open folder" action. + Q_PROPERTY(QString iconsDir READ iconsDir CONSTANT) + + /* Onboarding: the widget SetupWizard (LanguageWizardPage/JavaWizardPage) + * used to run before the main window -- including under the QML shell, + * which this replaces. See recomputeSetupSteps() for the rules, unchanged + * from Application::createSetupWizard(). */ + /// Ids of the wizard steps still needed, in the widget wizard's own + /// order: "language", then "java". Empty once nothing is needed. + Q_PROPERTY(QStringList setupSteps READ setupSteps NOTIFY setupStepsChanged) + /// The language picker's model; roles are `languageKey`, `name` (native + /// name), `completeness` (see TranslationsModel::roleNames()). + Q_PROPERTY(QObject* languages READ languages CONSTANT) + /// Detected Java installs; roles include `path`, `version`, + /// `architecture`, `recommended` (see BaseVersionList::roleNames(), which + /// JavaInstallList inherits). + Q_PROPERTY(QObject* javaInstalls READ javaInstalls CONSTANT) + /// Whether detectJava()'s task is still running. + Q_PROPERTY(bool javaDetecting READ javaDetecting NOTIFY javaDetectingChanged) + + public: + explicit QmlShell(QObject* parent = nullptr); + ~QmlShell() override; + + /* Loads the root window on first call; raises it on later ones. Returns + * false if the QML failed to load, in which case the reasons have already + * been logged. */ + bool show(bool minimized = false); + + /* The root window show() created, or nullptr before it has + * succeeded. PluginManager (MeshMC_logic, which links MeshMC_qml -- + * see setPluginSurfaceFactory()'s comment) uses this through + * Application::qmlShellWindow() to generalise main-window handling + * (show/hide/close-filter) to the QML shell the same way it already + * does for the widget MainWindow. */ + QWindow* window() const; + + /* Hands a C++-owned object to QML. The engine takes ownership of any + * QObject without a parent that crosses into JavaScript, and would delete + * core models out from under the rest of the launcher; this pins them. */ + static QObject* expose(QObject* object); + + /* + * QmlShell sits in MeshMC_qml, which links MeshMC_core only -- it + * cannot see PluginManager or PluginSurfaceModel, both of which live in + * MeshMC_logic (the plugin host), a target that links MeshMC_qml and + * not the other way around. Application installs the real factory once + * at startup (see Application::showMainWindow()), the same indirection + * launchInstance()/killInstance()/etc. use via *Requested() signals -- + * except pluginSurfaces() needs to *return* a model built from a + * PluginManager, where a signal handing back a value has no natural + * fit, hence a factory instead. Returns a C++-owned QObject* (a + * PluginSurfaceModel) for the given (anchor, anchorContext), or nullptr + * if no factory has been installed (e.g. a test/tool binary that never + * wires one up). + */ + using PluginSurfaceFactory = + std::function; + static void setPluginSurfaceFactory(PluginSurfaceFactory factory); + + /* One C++-owned model per (anchor, anchorContext) pair asked for, + * reused across calls the same way sectionModel() reuses one + * InstanceFilterModel per group. `anchor` is an MMCOUiAnchor value, or + * -1 for "every anchor"; `anchorContext` is an instance id, or empty + * for GLOBAL_SETTINGS / "every context" (see + * PluginManager::surfaces()). */ + Q_INVOKABLE QObject* pluginSurfaces(int anchor, + const QString& anchorContext = QString()); + + /* Sidebar account summary, read from LAUNCHER->accounts() - QmlShell has + * no reason to go through Application for this, and reaching it via the + * core keeps QmlShell usable without one. */ + QString accountName() const; + /// "Microsoft", "Offline", or empty when there is no default account. + QString accountKind() const; + int accountCount() const; + QString accountFace() const; + + QObject* settings() const; + /* Applies the ProxyType/ProxyAddr/ProxyPort/ProxyUser/ProxyPass + * settings immediately, the way the widget ProxyPage's apply button + * does. The QML settings page (once it has a proxy page of its own) + * writes those five settings through `settings` above like any other + * setting, then calls this so the change takes effect without a + * restart -- QmlShell cannot reach QNetworkProxy/Application itself, + * so this goes through LauncherContext::updateProxySettings(). */ + Q_INVOKABLE void applyProxySettings(); + /// Bound to `shell.uiHost` in QML -- see the Q_PROPERTY comment above. + QObject* uiHost() const; + /* The same object as uiHost() above, typed for Application's own use + * (Application::uiHost() prefers this over the widget UiHost while + * this is non-null) instead of QML's property binding. Null until + * show() has run *and* QmlUiHost::presenterReady() is true -- see + * QmlUiHost's class comment's PRESENTER READINESS section for why a + * call must not reach this object before some QML item can answer it. */ + UiHost* uiHostInterface() const; + int systemMemoryMiB() const; + QObject* modpackModel() const; + /* Starts installing a Modrinth modpack version as a new instance and + * returns its TaskWatcher, owned by C++: the install must outlive the + * page that started it, whatever QML does with the reference. */ + Q_INVOKABLE QObject* installModpack(const QString& projectId, + const QString& versionId, + const QString& instanceName, + const QString& group); + /* Installs version @p versionId of result @p row of the open + * instance's content browser; the TaskWatcher is C++-owned, like + * installModpack()'s. */ + Q_INVOKABLE QObject* installContent(int row, const QString& versionId); + QObject* recentModel() const; + QObject* heroModel() const; + QObject* instancePageModel() const; + QObject* recentWorlds() const; + /* The instances of one group (empty = ungrouped) that pass the search, + * for one section of the library. Created on first use and kept, so + * QML asking again from a rebuilt delegate gets the same model back. */ + Q_INVOKABLE QObject* sectionModel(const QString& group); + /* The InstanceDetails bridge for one instance's detail page. At most + * one is kept at a time: asking for a different id replaces (and + * destroys) whichever one was open before; asking again for the same + * id returns the one already open. Null if @p id names no instance. */ + Q_INVOKABLE QObject* instanceDetails(const QString& id); + /// The Accounts page's model+actions object. Created in show(), like + /// the other CONSTANT properties above. + QObject* accountsController() const; + /// The "New instance" flow's controller. Created in show(), like the + /// other CONSTANT properties above. + QObject* newInstance() const; + + /* Everything below just emits the matching *Requested() signal: QmlShell + * sits in MeshMC_qml, which cannot see the widget code that actually + * launches an instance, opens a dialog or shows a folder. Application + * connects these to the real actions. */ + Q_INVOKABLE void launchInstance(const QString& id); + Q_INVOKABLE void killInstance(const QString& id); + Q_INVOKABLE void editInstance(const QString& id); + Q_INVOKABLE void openInstanceFolder(const QString& id); + /* Launches instance @p id straight into server @p address (host[:port]), + * the QML-facing replacement for ServersPage's "Join" action + * (ServersPage::on_actionJoin_triggered()). */ + Q_INVOKABLE void joinServer(const QString& id, const QString& address); + /* No longer called from QML: the New instance dialog and Discover's + * "other platforms" button both used to route here, into the widget + * NewInstanceDialog with a null parent (see Application.cpp's + * createInstanceRequested connection) - that crashed under the QML + * shell. Both now go through NewInstanceController::create()/ + * importFrom() instead (see NewInstanceDialog.qml). Left in place + * only because Application.cpp still connects createInstanceRequested + * below; safe to delete both once that connection is removed too. */ + Q_INVOKABLE void createInstance(); + /// @p page: a classic settings page id ("accounts", "proxy-settings", + /// ...) to open on, or empty for the first one. + Q_INVOKABLE void openSettings(const QString& page = QString()); + /// Opens a folder in the file manager; relative paths are resolved + /// against the data folder, which is the working directory. + Q_INVOKABLE void openPath(const QString& path); + Q_INVOKABLE void manageAccounts(); + + /* Called by Application (see Application::showInstanceLog()) instead + * of raising a widget InstanceWindow when the QML shell is the active + * UI: a launch that would have opened the console -- ShowConsole, or a + * crash with ShowConsoleOnError -- relays the request into QML as + * openInstanceLog() below, which opens instance @p id's page on its + * Log tab. */ + void showInstanceLogRequested(const QString& id); + + /* Everything below replicates the core part of a MainWindow instance + * action directly against LAUNCHER->instances()/icons() -- no widget + * code, no dialogs (QML supplies its own and asks for confirmation + * itself where the widget would have). */ + + /// False (no change) for a name that trims to nothing, same rule the + /// widget's inline rename editor applies. + Q_INVOKABLE bool renameInstance(const QString& id, const QString& name); + /// "" ungroups; the same InstanceList API both the widget's + /// drag-to-group and its "Change group" dialog call. + Q_INVOKABLE void setInstanceGroup(const QString& id, + const QString& group); + Q_INVOKABLE void setInstanceIcon(const QString& id, + const QString& iconKey); + /* Installs a local image as a new icon, the way IconPickerDialog's + * "Add icon" button does (IconList::installIcons()). Returns whether + * the file looked installable (readable, a regular file); like the + * widget's own button, a same-key collision or a rejected extension is + * not detected here. On success, iconImported() follows once the icon + * list actually picks the new file up. */ + Q_INVOKABLE bool importIcon(const QString& fileUrlOrPath); + /* Starts the same InstanceCopyTask CopyInstanceDialog starts, with the + * same defaults its checkboxes start with (copy saves and keep + * playtime, both on) and the source instance's own icon. Returns a + * C++-owned TaskWatcher for the running copy, or null if @p id names no + * instance or @p newName trims to nothing. */ + Q_INVOKABLE QObject* duplicateInstance(const QString& id, + const QString& newName, + const QString& group); + /* The same deletion MainWindow performs once its confirmation dialog + * is accepted -- QML asks for confirmation itself before calling this. + * Refuses (false) while the instance is running, like the widget does. */ + Q_INVOKABLE bool deleteInstance(const QString& id); + Q_INVOKABLE bool isInstanceRunning(const QString& id) const; + /* Preview/dev-only: sets the same runtime flag LaunchTask sets around a + * real game process's exit (BaseInstance::setCrashed()), so a + * MESHMC_QML_ROUTE devRoute step (Main.qml's applyDevRoute(), "crashed= + * ") can exercise the Home page's "Crashed last time" chip. Unlike + * every other instance property, hasCrashed is never written to + * instance.cfg, so gen_preview.py's static preview data cannot fake it + * on disk -- this reaches the exact same in-memory flag production + * does instead of inventing a parallel on-disk one. No-op for an + * unknown id. */ + Q_INVOKABLE void debugMarkInstanceCrashed(const QString& id); + + QStringList groups() const; + QObject* iconsModel() const; + QString iconsDir() const; + + QStringList setupSteps() const; + /* Recomputes setupSteps() from live settings, exactly like + * Application::createSetupWizard() did once at startup for the widget + * wizard -- called once from the constructor, and again whenever QML + * says a step is done (finishSetupStep()), since finishing one step + * (e.g. picking a language) does not change whether another (Java) is + * still needed, but the shell has no other way to notice that a step it + * already reported is now satisfied. */ + Q_INVOKABLE void finishSetupStep(const QString& id); + QObject* languages() const; + /* Applies @p key live the way LanguageSelectionWidget's row-changed + * handler does (TranslationsModel::selectLanguage() + + * updateLanguage()), retranslates the running QML engine so qsTr() + * strings update immediately, and persists it as LanguageWizardPage's + * validatePage() does. */ + Q_INVOKABLE void selectLanguage(const QString& key); + QObject* javaInstalls() const; + bool javaDetecting() const; + /* Starts the same JavaInstallList detection task the widget wizard's + * refresh button runs (JavaSettingsWidget::refresh() -> + * VersionSelectWidget::loadList()) -- always a fresh run, not only when + * nothing has been detected yet. */ + Q_INVOKABLE void detectJava(); + /* Writes JavaPath the way JavaWizardPage::validatePage() does for a + * good result -- @p path is expected to already be a checked candidate + * (one of javaInstalls()'s rows), so this does not re-run JavaChecker. + * The wizard page never writes JavaVersion/JavaArchitecture itself + * (CheckJava, the launch step, does that later), so neither does this. */ + Q_INVOKABLE void useJava(const QString& path); + + signals: + /* Emitted when the root window really closes -- not merely when it + * is hidden (see eventFilter()): a plugin's main_window_hide(), or a + * plugin close-filter vetoing the close (main_window_install_close_filter()), + * both hide the window without this firing, the same way neither + * triggers MainWindow::isClosing() on the widget path. */ + void closed(); + + /// accountName()/accountKind()/accountCount() moved. + void accountChanged(); + + void launchRequested(const QString& id); + void killRequested(const QString& id); + void editRequested(const QString& id); + void folderRequested(const QString& id); + void joinServerRequested(const QString& id, const QString& address); + void createInstanceRequested(); + void settingsRequested(const QString& page); + void accountsRequested(); + + /// Emitted by showInstanceLogRequested() above -- asks QML to open + /// instance @p id's page on its Log tab. + void openInstanceLog(const QString& id); + + /// groups() moved. + void groupsChanged(); + /// importIcon() succeeded and the icon list now has @p key. + void iconImported(const QString& key); + + /// setupSteps() moved. + void setupStepsChanged(); + /// javaDetecting() moved. + void javaDetectingChanged(); + + private: + QVariantMap rootProperties(); + void scheduleSnapshotIfRequested(); + /* The actual rule-running: see setupSteps()'s Q_PROPERTY comment and + * finishSetupStep(). */ + void recomputeSetupSteps(); + + /* Installed on m_window by show(). Watches for the window's own + * QEvent::Close to emit closed() -- see the signal's doc comment + * above and the longer comment on the definition. */ + bool eventFilter(QObject* watched, QEvent* event) override; + + /* Declared before the engine so they are destroyed after it: QML holds + * pointers to both until the engine is gone. */ + std::unique_ptr m_instances; + std::unique_ptr m_recent; + std::unique_ptr m_hero; + std::unique_ptr m_instancePage; + std::unique_ptr m_recentWorlds; + // Declared after m_instances, their source, so they are destroyed first. + std::map> m_sections; + std::unique_ptr m_selection; + std::unique_ptr m_settings; + /* Created in show(), like m_settings above -- see uiHost()'s Q_PROPERTY + * comment for how Application reaches this. */ + std::unique_ptr m_uiHost; + std::unique_ptr m_modpacks; + /* The one open instance detail page, if any - see instanceDetails(). */ + std::unique_ptr m_instanceDetails; + std::unique_ptr m_accountsController; + mutable std::unique_ptr m_newInstance; + /* Keyed by (anchor, anchorContext) -- see pluginSurfaces(). Declared + * alongside the other cached models, for the same reason: destroyed + * before the engine is torn down. */ + std::map, std::unique_ptr> + m_pluginSurfaceModels; + + std::unique_ptr m_engine; + QQuickWindow* m_window = nullptr; + int m_accountRevision = 0; + + /* Onboarding -- see setupSteps()'s Q_PROPERTY comment. Not exposed as + * shared_ptr members the way m_instances etc. are: TranslationsModel and + * JavaInstallList are LauncherContext-owned singletons (LAUNCHER-> + * translations()/javalist()), shared with the rest of the launcher and + * outliving any one QmlShell, so there is nothing here to own. */ + QStringList m_setupSteps; + bool m_javaDetecting = false; +}; diff --git a/launcher/qml/QmlShell_test.cpp b/launcher/qml/QmlShell_test.cpp new file mode 100644 index 00000000..d3893f7a --- /dev/null +++ b/launcher/qml/QmlShell_test.cpp @@ -0,0 +1,96 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include + +#include "qml/QmlShell.h" + +/* + * Covers sanitizedInstanceName() and the setup-step-needed rules + * (languageSetupStepNeeded()/javaSetupStepNeeded()), the free-standing + * helpers QmlShell::renameInstance()/duplicateInstance() and + * recomputeSetupSteps() build on. They are free-standing rather than QmlShell + * methods precisely so this can run without a LauncherContext, the way + * NewInstanceController_test.cpp exercises composeSuggestedInstanceName() + * without a NewInstanceController. + */ +class QmlShellTest : public QObject +{ + Q_OBJECT + + private slots: + void leavesAnAlreadyCleanNameUnchanged() + { + QCOMPARE(sanitizedInstanceName(QStringLiteral("Vanilla 1.20.1")), + QStringLiteral("Vanilla 1.20.1")); + } + + void trimsSurroundingWhitespace() + { + QCOMPARE(sanitizedInstanceName(QStringLiteral(" Modded Survival ")), + QStringLiteral("Modded Survival")); + } + + void collapsesEmbeddedNewlinesToSpaces() + { + // Same as NoReturnTextEdit's commit path (InstanceDelegate.cpp): + // a pasted multi-line name becomes one line, not several. + QCOMPARE(sanitizedInstanceName(QStringLiteral("Line one\nLine two")), + QStringLiteral("Line one Line two")); + } + + void whollyBlankNameSanitizesToEmpty() + { + QVERIFY(sanitizedInstanceName(QStringLiteral(" \n ")).isEmpty()); + } + + void emptyNameStaysEmpty() + { + QVERIFY(sanitizedInstanceName(QString()).isEmpty()); + } + + void languageStepNeededOnlyWhenLanguageIsEmpty() + { + QVERIFY(languageSetupStepNeeded(QString())); + QVERIFY(!languageSetupStepNeeded(QStringLiteral("en_US"))); + } + + void javaStepNeededWhenHostnameChangedRegardlessOfJavaPath() + { + QVERIFY(javaSetupStepNeeded(/* hostnameChanged */ true, + /* javaPathResolves */ true)); + QVERIFY(javaSetupStepNeeded(true, false)); + } + + void javaStepNeededWhenJavaPathDoesNotResolve() + { + QVERIFY(javaSetupStepNeeded(/* hostnameChanged */ false, + /* javaPathResolves */ false)); + } + + void javaStepNotNeededWhenHostnameSameAndJavaPathResolves() + { + QVERIFY(!javaSetupStepNeeded(/* hostnameChanged */ false, + /* javaPathResolves */ true)); + } +}; + +QTEST_GUILESS_MAIN(QmlShellTest) + +#include "QmlShell_test.moc" diff --git a/launcher/qml/QmlUiHost.cpp b/launcher/qml/QmlUiHost.cpp new file mode 100644 index 00000000..7bf3cd1c --- /dev/null +++ b/launcher/qml/QmlUiHost.cpp @@ -0,0 +1,730 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "qml/QmlUiHost.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "BuildConfig.h" +#include "DesktopServices.h" +#include "minecraft/auth/AuthRequest.h" +#include "modplatform/flame/FlameApi.h" + +namespace +{ + QString severityToString(UiHost::Severity severity) + { + switch (severity) { + case UiHost::Severity::Information: + return QStringLiteral("info"); + case UiHost::Severity::Question: + return QStringLiteral("question"); + case UiHost::Severity::Warning: + return QStringLiteral("warning"); + case UiHost::Severity::Critical: + return QStringLiteral("error"); + } + return QStringLiteral("info"); + } + + /* Same guard QmlShell::expose() applies to everything it hands to + * QML: without it, the engine would try to garbage-collect a QObject + * that a C++ stack frame -- here, whichever UiHost call is still + * running -- still owns. Kept local rather than reusing + * QmlShell::expose() so this file does not need to know QmlShell + * exists. */ + QObject* exposeToQml(QObject* object) + { + if (object) { + QQmlEngine::setObjectOwnership(object, QQmlEngine::CppOwnership); + } + return object; + } + + /* Indeterminate and uncancellable, like WidgetUiHost's own + * ProgressDialogBusy -- there is nothing here for the caller to + * report progress on or to abort. */ + class BusyToken final : public UiHost::BusyIndicator + { + public: + BusyToken(QmlUiHost* host, int id) : m_host(host), m_id(id) + { + } + + ~BusyToken() override + { + if (m_host) { + m_host->endBusy(m_id); + } + } + + private: + QPointer m_host; + int m_id; + }; +} // namespace + +/* ---------------------------------------------------------------------- */ +/* QmlUiRequest */ +/* ---------------------------------------------------------------------- */ + +QmlUiRequest::QmlUiRequest(Kind kind, QString title, QString text, + QObject* parent) + : QObject(parent), m_kind(kind), m_title(std::move(title)), + m_text(std::move(text)) +{ + if (m_kind == Kind::UntrustedMods) { + /* Same friction UntrustedModsDialog applies with its checkbox -- + * see kConfirmDelayMs there. */ + m_confirmDelayMs = 3000; + } +} + +QString QmlUiRequest::kind() const +{ + switch (m_kind) { + case Kind::Message: + return QStringLiteral("message"); + case Kind::Confirm: + return QStringLiteral("confirm"); + case Kind::Choose: + return QStringLiteral("choose"); + case Kind::Text: + return QStringLiteral("text"); + case Kind::BlockedMods: + return QStringLiteral("blockedMods"); + case Kind::UntrustedMods: + return QStringLiteral("untrustedMods"); + case Kind::Update: + return QStringLiteral("update"); + case Kind::ProfileSetup: + return QStringLiteral("profileSetup"); + case Kind::FilePicker: + return QStringLiteral("filePicker"); + } + return QString(); +} + +void QmlUiRequest::setSeverity(UiHost::Severity severity) +{ + m_severity = severityToString(severity); +} + +void QmlUiRequest::setLabels(QString acceptLabel, QString rejectLabel) +{ + m_acceptLabel = std::move(acceptLabel); + m_rejectLabel = std::move(rejectLabel); +} + +void QmlUiRequest::setActions(QStringList actions) +{ + m_actions = std::move(actions); +} + +void QmlUiRequest::setValue(QString value) +{ + m_value = std::move(value); +} + +void QmlUiRequest::setBlockedMods(const QList& mods) +{ + m_blockedMods = mods; + emit blockedModsChanged(); +} + +void QmlUiRequest::setUntrustedModsFiles(QStringList files) +{ + m_untrustedModsFiles = std::move(files); +} + +void QmlUiRequest::setUpdateInfo(QString currentVersion, + QString availableVersion, + QString releaseNotes) +{ + m_updateInfo = QVariantMap{ + { QStringLiteral("currentVersion"), currentVersion }, + { QStringLiteral("availableVersion"), availableVersion }, + { QStringLiteral("releaseNotes"), releaseNotes }, + }; +} + +void QmlUiRequest::setFilePicker(QString mode, QString defaultPath, + QString filter) +{ + m_filePickerMode = std::move(mode); + m_filePickerDefaultPath = std::move(defaultPath); + m_filePickerFilter = std::move(filter); +} + +void QmlUiRequest::setProfileNameStatus(QString status, QString error) +{ + m_profileNameStatus = std::move(status); + m_profileNameError = std::move(error); + emit profileNameStatusChanged(); +} + +void QmlUiRequest::setProfileSubmitting(bool submitting) +{ + if (m_profileSubmitting == submitting) { + return; + } + m_profileSubmitting = submitting; + emit profileSubmittingChanged(); +} + +QVariantList QmlUiRequest::blockedMods() const +{ + QVariantList list; + list.reserve(m_blockedMods.size()); + for (const auto& mod : m_blockedMods) { + list.append(QVariantMap{ + { QStringLiteral("fileName"), mod.fileName }, + { QStringLiteral("targetPath"), mod.targetPath }, + { QStringLiteral("downloadUrl"), + FlameApi::browserDownloadUrl(QString::number(mod.projectId), + QString::number(mod.fileId)) }, + { QStringLiteral("found"), mod.found }, + }); + } + return list; +} + +void QmlUiRequest::accept() +{ + if (m_answered) { + return; + } + m_answered = true; + m_accepted = true; + emit answered(); +} + +void QmlUiRequest::accept(const QString& text) +{ + if (m_answered) { + return; + } + m_answered = true; + m_accepted = true; + m_answeredText = text; + emit answered(); +} + +void QmlUiRequest::reject() +{ + if (m_answered) { + return; + } + m_answered = true; + m_accepted = false; + m_chosenIndex = -1; + m_updateChoice = UiHost::UpdateChoice::Later; + emit answered(); +} + +void QmlUiRequest::choose(int index) +{ + if (m_answered) { + return; + } + m_answered = true; + m_chosenIndex = index; + m_accepted = index >= 0; + emit answered(); +} + +void QmlUiRequest::answerUpdate(const QString& choice) +{ + if (m_answered) { + return; + } + m_answered = true; + if (choice == QLatin1String("install")) { + m_updateChoice = UiHost::UpdateChoice::Install; + } else if (choice == QLatin1String("skip")) { + m_updateChoice = UiHost::UpdateChoice::Skip; + } else { + m_updateChoice = UiHost::UpdateChoice::Later; + } + emit answered(); +} + +void QmlUiRequest::openDownload(int index) +{ + if (index < 0 || index >= m_blockedMods.size()) { + return; + } + const auto& mod = m_blockedMods[index]; + const QString url = FlameApi::browserDownloadUrl( + QString::number(mod.projectId), QString::number(mod.fileId)); + DesktopServices::openUrl(QUrl(url)); +} + +void QmlUiRequest::rescanDownloads() +{ + emit rescanRequested(); +} + +void QmlUiRequest::checkProfileName(const QString& name) +{ + /* Same shape ("[a-zA-Z0-9_]{3,16}") ProfileSetupDialog.cpp validates + * the field with before ever asking the network -- checked here, + * locally, so a name that can never be valid does not cost a round + * trip. */ + static const QRegularExpression permittedName( + QStringLiteral("^[a-zA-Z0-9_]{3,16}$")); + if (!permittedName.match(name).hasMatch()) { + setProfileNameStatus( + QStringLiteral("unset"), + tr("Name must be 3-16 characters long: letters, numbers and " + "underscores only.")); + return; + } + setProfileNameStatus(QStringLiteral("pending"), QString()); + ++m_checkSequence; + emit checkNameRequested(name); +} + +void QmlUiRequest::submitProfileName(const QString& name) +{ + if (m_profileNameStatus != QStringLiteral("available") || + m_profileSubmitting) { + return; + } + setProfileSubmitting(true); + emit submitNameRequested(name); +} + +/* ---------------------------------------------------------------------- */ +/* QmlUiHost */ +/* ---------------------------------------------------------------------- */ + +QmlUiHost::QmlUiHost(QObject* parent) : QObject(parent) +{ +} + +QmlUiHost::~QmlUiHost() +{ + /* Every request still on the stack belongs to a runRequest() call + * further down the C++ call stack (see the class comment) -- still + * perfectly valid objects, just not owned by this one. Rejecting each + * lets those calls unwind instead of waiting forever for an answer + * nobody can give once this object is gone; runRequest() notices this + * object is gone (QPointer) when it resumes and skips touching it. */ + for (QmlUiRequest* request : std::as_const(m_stack)) { + request->reject(); + } +} + +QObject* QmlUiHost::current() const +{ + return m_stack.isEmpty() ? nullptr : exposeToQml(m_stack.last()); +} + +bool QmlUiHost::presenterReady() const +{ + return m_presenterReady; +} + +void QmlUiHost::setPresenterReady(bool ready) +{ + if (m_presenterReady == ready) { + return; + } + m_presenterReady = ready; + emit presenterReadyChanged(); +} + +bool QmlUiHost::busy() const +{ + return !m_busy.isEmpty(); +} + +QString QmlUiHost::busyText() const +{ + return m_busy.isEmpty() ? QString() : m_busy.last().text; +} + +void QmlUiHost::endBusy(int id) +{ + for (int i = 0; i < m_busy.size(); ++i) { + if (m_busy[i].id == id) { + m_busy.removeAt(i); + emit busyChanged(); + return; + } + } +} + +std::unique_ptr QmlUiHost::showBusy(const QString& text) +{ + const int id = m_nextBusyId++; + m_busy.append({ id, text }); + emit busyChanged(); + return std::make_unique(this, id); +} + +void QmlUiHost::runRequest(QmlUiRequest& request) +{ + /* Diagnostic for exactly the failure PRESENTER READINESS (see the class + * comment) guards against: a call reaching this object before anything + * routes here only once ready, or a future bug in that gate, hangs + * silently otherwise -- this line is what tells a log reader which + * call it was. */ + qDebug() << "QmlUiHost: asking" << request.kind() << "-" << request.title(); + + QEventLoop loop; + /* Queued rather than the default direct connection: a QML binding + * reacting to currentChanged() below could in principle answer the + * request synchronously, before this function reaches loop.exec() -- + * a direct connection would call loop.quit() before there is a loop + * to quit yet. Queuing means the call is delivered once the loop is + * actually pumping events, whichever order these end up running in. */ + connect(&request, &QmlUiRequest::answered, &loop, &QEventLoop::quit, + Qt::QueuedConnection); + /* A quit is answered the same way reject() answers it, so a task + * blocked here cannot keep the application from exiting -- it sees + * the same "gave up" result a real "no" would have produced. */ + connect(qApp, &QCoreApplication::aboutToQuit, &loop, + [&request]() { request.reject(); }, Qt::QueuedConnection); + + m_stack.append(&request); + emit currentChanged(); + + /* Constructed *before* loop.exec(), while this object is definitely + * still alive: QPointer only guards safely against a destruction it + * was watching for from the start -- building one from `this` after + * the fact, once this object might already be gone, would dereference + * freed memory instead of reporting null. */ + QPointer self(this); + + loop.exec(); + + /* This object may already be gone -- destroyed while `request` was + * still pending, which rejected it exactly the way the branch above + * does (see the destructor) and is why loop.exec() just returned. + * Nothing below may run in that case. */ + if (self) { + Q_ASSERT(!m_stack.isEmpty() && m_stack.last() == &request); + m_stack.removeLast(); + emit currentChanged(); + } +} + +void QmlUiHost::message(const QString& title, const QString& text, + Severity severity) +{ + QmlUiRequest request(QmlUiRequest::Kind::Message, title, text); + request.setSeverity(severity); + runRequest(request); +} + +bool QmlUiHost::confirm(const QString& title, const QString& text, + Severity severity, const QString& acceptLabel, + const QString& rejectLabel) +{ + QmlUiRequest request(QmlUiRequest::Kind::Confirm, title, text); + request.setSeverity(severity); + request.setLabels(acceptLabel, rejectLabel); + runRequest(request); + return request.accepted(); +} + +int QmlUiHost::choose(const QString& title, const QString& text, + Severity severity, const QStringList& actions) +{ + QmlUiRequest request(QmlUiRequest::Kind::Choose, title, text); + request.setSeverity(severity); + request.setActions(actions); + runRequest(request); + return request.chosenIndex(); +} + +std::optional QmlUiHost::askText(const QString& title, + const QString& text, + const QString& defaultValue) +{ + QmlUiRequest request(QmlUiRequest::Kind::Text, title, text); + request.setValue(defaultValue); + runRequest(request); + if (!request.accepted()) { + return std::nullopt; + } + return request.answeredText(); +} + +bool QmlUiHost::resolveBlockedMods(const QString& title, const QString& text, + QList& mods) +{ + QmlUiRequest request(QmlUiRequest::Kind::BlockedMods, title, text); + request.setBlockedMods(mods); + + /* Same Downloads-folder watch BlockedModsDialog::setupWatch() and + * scanDownloadsFolder() run, kept here rather than in QmlUiRequest so + * the request stays a plain data-plus-answer object -- this is the + * one kind whose data changes while it is pending, not just once. */ + const QString downloadDir = + QStandardPaths::writableLocation(QStandardPaths::DownloadLocation); + QFileSystemWatcher watcher; + auto rescan = [&]() { + if (downloadDir.isEmpty()) { + return; + } + const QStringList files = QDir(downloadDir).entryList(QDir::Files); + bool changed = false; + for (auto& mod : mods) { + if (!mod.found && files.contains(mod.fileName)) { + mod.found = true; + changed = true; + } + } + if (changed) { + request.setBlockedMods(mods); + } + }; + if (!downloadDir.isEmpty() && QDir(downloadDir).exists()) { + watcher.addPath(downloadDir); + connect(&watcher, &QFileSystemWatcher::directoryChanged, &watcher, + [&rescan](const QString&) { rescan(); }); + } + connect(&request, &QmlUiRequest::rescanRequested, &request, + [&rescan]() { rescan(); }); + rescan(); // same initial scan the widget dialog runs from its constructor + + runRequest(request); + return request.accepted(); +} + +bool QmlUiHost::confirmUntrustedMods(const QStringList& suspectPaths) +{ + /* WidgetUiHost's version of this question carries no title/text of its + * own -- UntrustedModsDialog.ui hardcodes them -- so this reproduces + * that copy verbatim for QML to show the same way it shows any other + * request's title/text. */ + QmlUiRequest request( + QmlUiRequest::Kind::UntrustedMods, tr("Easy There!"), + tr("This modpack installs code that is not hosted on Modrinth or " + "CurseForge - either downloaded from another host, or carried " + "inside the pack itself.\n\n" + "Malicious mods are often distributed through links sent on " + "platforms such as Discord. We strongly recommend only " + "importing modpacks from trusted sources.")); + request.setSeverity(Severity::Warning); + request.setLabels(tr("Install anyway"), QString()); + request.setUntrustedModsFiles(suspectPaths); + runRequest(request); + return request.accepted(); +} + +UiHost::UpdateChoice QmlUiHost::offerUpdate(const QString& currentVersion, + const QString& availableVersion, + const QString& releaseNotes) +{ + QmlUiRequest request( + QmlUiRequest::Kind::Update, + tr("A new version of %1 is available!") + .arg(BuildConfig.MESHMC_DISPLAYNAME), + tr("Version %1 is now available - you have %2 . Would you like to " + "download it now?") + .arg(availableVersion, currentVersion)); + request.setUpdateInfo(currentVersion, availableVersion, releaseNotes); + runRequest(request); + return request.updateChoice(); +} + +namespace +{ + /* Mirrors ProfileSetupDialog.cpp's checkFinished()/setupProfileFinished() + * -- kept as a separate copy rather than shared with the widget dialog + * (which stays untouched, per the audit's "widget UI keeps its dialog") + * the same way PluginSurfaceModel duplicates PluginUiRenderer's node + * handling instead of sharing it (see PluginSurfaceModel.cpp). */ + QString minecraftServicesBearer(const MinecraftAccountPtr& account) + { + return QStringLiteral("Bearer %1") + .arg(account ? account->accessToken() : QString()); + } +} // namespace + +bool QmlUiHost::setupProfile(MinecraftAccountPtr account) +{ + QmlUiRequest request( + QmlUiRequest::Kind::ProfileSetup, tr("Choose a Minecraft name"), + tr("This Microsoft account has never set up a Minecraft profile " + "before. Pick a username to play with -- it can be changed " + "again later at minecraft.net.")); + + connect(&request, &QmlUiRequest::checkNameRequested, &request, + [&request, account](const QString& name) { + auto* checkReq = new AuthRequest(&request); + QPointer guard(&request); + /* checkNameRequested is a direct connection (both objects + * are on this thread), so checkSequence() here already + * reflects the bump checkProfileName() just made for this + * exact call -- capturing it now gives this network round + * trip a fixed identity to compare against whatever the + * latest check is once the response comes back, however + * much later that is or however many newer checks have + * started in between (see QmlUiRequest::checkSequence()'s + * comment; mirrors ProfileSetupDialog's isChecking/ + * currentCheck guard). */ + const int seq = request.checkSequence(); + connect( + checkReq, &AuthRequest::finished, &request, + [guard, checkReq, name, + seq](QNetworkReply::NetworkError error, QByteArray data, + QList) { + checkReq->deleteLater(); + if (!guard) { + return; + } + if (guard->checkSequence() != seq) { + /* A newer checkProfileName() call has started + * (or already finished) since this one was + * issued -- this response is for a name the UI + * has moved on from; applying it now would + * overwrite a newer, still-in-flight or already + * -settled status with a stale one. */ + return; + } + if (error != QNetworkReply::NoError) { + guard->setProfileNameStatus( + QStringLiteral("error"), + tr("Failed to check name availability.")); + return; + } + const auto root = + QJsonDocument::fromJson(data).object(); + const auto status = root.value("status").toString( + QStringLiteral("INVALID")); + if (status == QLatin1String("AVAILABLE")) { + guard->setProfileNameStatus( + QStringLiteral("available"), QString()); + } else if (status == QLatin1String("DUPLICATE")) { + guard->setProfileNameStatus( + QStringLiteral("exists"), + tr("A Minecraft profile named %1 already " + "exists.") + .arg(name)); + } else if (status == QLatin1String("NOT_ALLOWED")) { + guard->setProfileNameStatus( + QStringLiteral("notAllowed"), + tr("The name %1 is not allowed.").arg(name)); + } else { + guard->setProfileNameStatus( + QStringLiteral("error"), + tr("Unhandled profile name status: %1") + .arg(status)); + } + }); + QNetworkRequest netReq{ QUrl( + QStringLiteral("https://api.minecraftservices.com/" + "minecraft/profile/name/%1/available") + .arg(name)) }; + netReq.setHeader(QNetworkRequest::ContentTypeHeader, + "application/json"); + netReq.setRawHeader("Accept", "application/json"); + netReq.setRawHeader( + "Authorization", + minecraftServicesBearer(account).toUtf8()); + checkReq->get(netReq); + }); + + connect( + &request, &QmlUiRequest::submitNameRequested, &request, + [&request, account](const QString& name) { + auto* submitReq = new AuthRequest(&request); + QPointer guard(&request); + connect(submitReq, &AuthRequest::finished, &request, + [guard, submitReq](QNetworkReply::NetworkError error, + QByteArray data, + QList) { + submitReq->deleteLater(); + if (!guard) { + return; + } + guard->setProfileSubmitting(false); + if (error == QNetworkReply::NoError) { + /* Same as ProfileSetupDialog::setupProfileFinished(): + * the response has the new profile in it, but + * there is nothing here that needs it -- the + * caller (LaunchController) just re-fills the + * session and continues the normal login flow. */ + guard->accept(); + return; + } + const auto root = + QJsonDocument::fromJson(data).object(); + const QString message = + root.value("errorMessage").toString(); + guard->setProfileNameStatus( + QStringLiteral("error"), + message.isEmpty() + ? tr("Failed to create the profile.") + : message); + }); + QNetworkRequest netReq{ QUrl(QStringLiteral( + "https://api.minecraftservices.com/minecraft/profile")) }; + netReq.setHeader(QNetworkRequest::ContentTypeHeader, + "application/json"); + netReq.setRawHeader("Accept", "application/json"); + netReq.setRawHeader("Authorization", + minecraftServicesBearer(account).toUtf8()); + const QByteArray body = + QStringLiteral("{\"profileName\":\"%1\"}").arg(name).toUtf8(); + submitReq->post(netReq, body); + }); + + runRequest(request); + return request.accepted(); +} + +std::optional QmlUiHost::pickFile(FilePickerMode mode, + const QString& title, + const QString& defaultPath, + const QString& filter) +{ + QmlUiRequest request(QmlUiRequest::Kind::FilePicker, title, QString()); + request.setFilePicker(mode == FilePickerMode::Open + ? QStringLiteral("open") + : QStringLiteral("save"), + defaultPath, filter); + runRequest(request); + if (!request.accepted()) { + return std::nullopt; + } + /* QML hands back whatever a QtQuick.Dialogs FileDialog's selectedFile + * gives it -- a file:// URL string, the same convention + * InstanceDetails::importIcon() and NewInstanceController already + * unwrap this way for a QML FileDialog's result. */ + const QUrl url(request.answeredText()); + return url.isLocalFile() ? url.toLocalFile() : request.answeredText(); +} diff --git a/launcher/qml/QmlUiHost.h b/launcher/qml/QmlUiHost.h new file mode 100644 index 00000000..4e87bc34 --- /dev/null +++ b/launcher/qml/QmlUiHost.h @@ -0,0 +1,480 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include +#include +#include + +#include + +#include "core/UiHost.h" + +/* + * One question QmlUiHost is asking, published to QML as `current` while it + * is unanswered. + * + * `kind` decides which of the other properties apply and which of the + * Q_INVOKABLEs is the right way to answer: + * message -- accept() only (a plain acknowledgement) + * confirm -- accept() / reject() + * choose -- choose(index); actions holds the button labels + * text -- value holds the pre-filled default; accept(QString) / + * reject() + * blockedMods -- blockedMods (live), openDownload(index), + * rescanDownloads(), then accept() / reject() + * untrustedMods -- untrustedModsFiles, confirmDelayMs, then + * accept() / reject() + * update -- updateInfo, then answerUpdate("install"|"later"|"skip") + * profileSetup -- profileNameStatus (live), checkProfileName(name), + * submitProfileName(name); accept() fires itself once + * the profile is actually created, reject() cancels + * filePicker -- filePickerMode/filePickerFilter/filePickerDefaultPath, + * then accept(path) / reject() (QML shows a native + * QtQuick.Dialogs file dialog rather than this + * request's own UI for this kind) + * + * Ownership is C++'s throughout: QmlUiHost hands this to QML with + * QQmlEngine::CppOwnership (see exposeToQml() in the .cpp) and deletes it + * itself once answered, the same guard QmlShell::expose() applies to + * everything else it publishes. + */ +class QmlUiRequest : public QObject +{ + Q_OBJECT + + Q_PROPERTY(QString kind READ kind CONSTANT) + Q_PROPERTY(QString title READ title CONSTANT) + Q_PROPERTY(QString text READ text CONSTANT) + /// "info" | "question" | "warning" | "error"; "info" where UiHost's call + /// carries no severity of its own (blockedMods, update). + Q_PROPERTY(QString severity READ severity CONSTANT) + Q_PROPERTY(QString acceptLabel READ acceptLabel CONSTANT) + Q_PROPERTY(QString rejectLabel READ rejectLabel CONSTANT) + /// Button labels for kind == "choose"; empty otherwise. + Q_PROPERTY(QStringList actions READ actions CONSTANT) + /// kind == "text": the value to pre-fill the field with; empty otherwise. + Q_PROPERTY(QString value READ value CONSTANT) + /* kind == "blockedMods": one entry per mod, in the order + * resolveBlockedMods() received them -- {fileName, targetPath, + * downloadUrl, found}. NOTIFY rather than CONSTANT: QmlUiHost watches + * the Downloads folder for as long as this request is pending and + * updates `found` live (see QmlUiHost::resolveBlockedMods()). */ + Q_PROPERTY(QVariantList blockedMods READ blockedMods NOTIFY blockedModsChanged) + /// kind == "untrustedMods": the files in question, instance-relative. + Q_PROPERTY(QStringList untrustedModsFiles READ untrustedModsFiles CONSTANT) + /* kind == "untrustedMods": how long, in milliseconds, QML should keep + * its accept action out of reach after showing this -- the same + * deliberate friction UntrustedModsDialog applies with its checkbox + * (see kConfirmDelayMs in UntrustedModsDialog.cpp). Zero for every + * other kind. This is a hint for QML to apply; nothing here enforces + * it. */ + Q_PROPERTY(int confirmDelayMs READ confirmDelayMs CONSTANT) + /// kind == "update": currentVersion, availableVersion, releaseNotes + /// (Markdown, exactly as published -- rendering it is QML's job, the + /// way HoeDown was the widget dialog's). + Q_PROPERTY(QVariantMap updateInfo READ updateInfo CONSTANT) + /* kind == "profileSetup": "unset" | "pending" | "available" | "exists" | + * "notAllowed" | "error" -- see checkProfileName()/submitProfileName(). + * NOTIFY rather than CONSTANT: this is the one other property (besides + * blockedMods) that changes live while the request is pending. */ + Q_PROPERTY(QString profileNameStatus READ profileNameStatus NOTIFY + profileNameStatusChanged) + /// kind == "profileSetup": human-readable detail for the status above + /// ("name too short", "already exists", a server error); empty when + /// there is nothing to show. + Q_PROPERTY(QString profileNameError READ profileNameError NOTIFY + profileNameStatusChanged) + /// kind == "profileSetup": true while submitProfileName()'s network + /// call is in flight -- QML disables its form while this is true. + Q_PROPERTY(bool profileSubmitting READ profileSubmitting NOTIFY + profileSubmittingChanged) + /// kind == "filePicker": "open" | "save". + Q_PROPERTY(QString filePickerMode READ filePickerMode CONSTANT) + /// kind == "filePicker": a Qt filter string, as the plugin gave it. + Q_PROPERTY(QString filePickerFilter READ filePickerFilter CONSTANT) + /// kind == "filePicker": FilePickerMode::Save's suggested filename; + /// empty otherwise. + Q_PROPERTY(QString filePickerDefaultPath READ filePickerDefaultPath + CONSTANT) + + public: + enum class Kind { + Message, + Confirm, + Choose, + Text, + BlockedMods, + UntrustedMods, + Update, + ProfileSetup, + FilePicker, + }; + + QmlUiRequest(Kind kind, QString title, QString text, + QObject* parent = nullptr); + + QString kind() const; + QString title() const + { + return m_title; + } + QString text() const + { + return m_text; + } + QString severity() const + { + return m_severity; + } + QString acceptLabel() const + { + return m_acceptLabel; + } + QString rejectLabel() const + { + return m_rejectLabel; + } + QStringList actions() const + { + return m_actions; + } + QString value() const + { + return m_value; + } + QVariantList blockedMods() const; + QStringList untrustedModsFiles() const + { + return m_untrustedModsFiles; + } + int confirmDelayMs() const + { + return m_confirmDelayMs; + } + QVariantMap updateInfo() const + { + return m_updateInfo; + } + QString profileNameStatus() const + { + return m_profileNameStatus; + } + QString profileNameError() const + { + return m_profileNameError; + } + bool profileSubmitting() const + { + return m_profileSubmitting; + } + QString filePickerMode() const + { + return m_filePickerMode; + } + QString filePickerFilter() const + { + return m_filePickerFilter; + } + QString filePickerDefaultPath() const + { + return m_filePickerDefaultPath; + } + + /* Filled in by QmlUiHost before the request is published (from + * runRequest()'s caller, never after) -- not reachable from QML, which + * only ever sees the getters above. */ + void setSeverity(UiHost::Severity severity); + void setLabels(QString acceptLabel, QString rejectLabel); + void setActions(QStringList actions); + void setValue(QString value); + void setBlockedMods(const QList& mods); + void setUntrustedModsFiles(QStringList files); + void setUpdateInfo(QString currentVersion, QString availableVersion, + QString releaseNotes); + void setFilePicker(QString mode, QString defaultPath, QString filter); + /* Read by QmlUiHost's checkNameRequested/submitNameRequested handlers + * (which do the actual network work -- see QmlUiHost::setupProfile()) + * to update what QML sees; not reachable from QML itself, which only + * ever sees the getters above. */ + void setProfileNameStatus(QString status, QString error); + void setProfileSubmitting(bool submitting); + /* Bumped by checkProfileName() every time it actually starts a network + * check (not on the local-validation-only path), before it emits + * checkNameRequested(). QmlUiHost::setupProfile()'s connected slot reads + * this synchronously -- the connection is direct, so the read happens + * inside the same call stack as the increment -- and captures it as the + * check's identity; a result that comes back once a newer check has + * started is discarded by comparing against this again. Mirrors + * ProfileSetupDialog's isChecking/currentCheck guard (see + * QmlUiHost::setupProfile()'s comment) without serializing the checks + * themselves. */ + int checkSequence() const + { + return m_checkSequence; + } + + /* Read by QmlUiHost once answered() has fired; meaningless before + * then. */ + bool accepted() const + { + return m_accepted; + } + int chosenIndex() const + { + return m_chosenIndex; + } + UiHost::UpdateChoice updateChoice() const + { + return m_updateChoice; + } + /// kind == "text": the text accept(QString) was called with; meaningless + /// unless accepted() is true. + QString answeredText() const + { + return m_answeredText; + } + + /// kind == "message": the acknowledgement. kind == "confirm" / + /// "blockedMods" / "untrustedMods": the positive answer. + Q_INVOKABLE void accept(); + /// kind == "text": commits @p text as the answer -- the counterpart to + /// accept() above for the one kind that hands back a value rather than + /// a plain yes. + Q_INVOKABLE void accept(const QString& text); + /// The negative answer, or "back out" -- same as closing the widget + /// dialogs did. Valid for every kind except "choose" and "update", + /// which have their own invokables below. + Q_INVOKABLE void reject(); + /// kind == "choose": answers with the index into `actions`, or a + /// negative index for "backed out" (see UiHost::choose()'s doc + /// comment). + Q_INVOKABLE void choose(int index); + /// kind == "update": one of "install", "later", "skip". Anything else + /// is treated as "later". + Q_INVOKABLE void answerUpdate(const QString& choice); + /// kind == "blockedMods": opens mod @p index's download page in the + /// system browser, the way BlockedModsDialog's per-row button did. + /// No-op for an out-of-range index. + Q_INVOKABLE void openDownload(int index); + /// kind == "blockedMods": forces an immediate re-check of the + /// Downloads folder instead of waiting for the next filesystem event. + Q_INVOKABLE void rescanDownloads(); + /// kind == "profileSetup": checks whether @p name is a valid, available + /// Minecraft profile name -- validates the shape locally (3-16 + /// letters/digits/underscores, the same rule the widget dialog's field + /// validator enforces) before asking QmlUiHost to check availability + /// over the network; updates profileNameStatus either way. + Q_INVOKABLE void checkProfileName(const QString& name); + /// kind == "profileSetup": creates the profile with @p name, the way + /// the widget dialog's OK button does. No-op unless profileNameStatus + /// is currently "available". Answers the request with accept() on + /// success; on failure, sets profileNameStatus to "error" and leaves + /// the request open so the user can try another name. + Q_INVOKABLE void submitProfileName(const QString& name); + + signals: + /// One of the answer invokables above ran; QmlUiHost::runRequest() is + /// waiting on this to leave its event loop. + void answered(); + void blockedModsChanged(); + /// rescanDownloads() was called; QmlUiHost::resolveBlockedMods() + /// connects this to the scan it already has running. + void rescanRequested(); + void profileNameStatusChanged(); + void profileSubmittingChanged(); + /// checkProfileName() passed local validation; QmlUiHost::setupProfile() + /// connects this to the actual network check. + void checkNameRequested(const QString& name); + /// submitProfileName() was called while available; QmlUiHost::setupProfile() + /// connects this to the actual profile-creation call. + void submitNameRequested(const QString& name); + + private: + Kind m_kind; + QString m_title; + QString m_text; + QString m_severity = QStringLiteral("info"); + QString m_acceptLabel; + QString m_rejectLabel; + QStringList m_actions; + QString m_value; + QString m_answeredText; + QList m_blockedMods; + QStringList m_untrustedModsFiles; + int m_confirmDelayMs = 0; + QVariantMap m_updateInfo; + QString m_profileNameStatus = QStringLiteral("unset"); + QString m_profileNameError; + bool m_profileSubmitting = false; + QString m_filePickerMode; + QString m_filePickerFilter; + QString m_filePickerDefaultPath; + int m_checkSequence = 0; + + bool m_answered = false; + bool m_accepted = false; + int m_chosenIndex = -1; + UiHost::UpdateChoice m_updateChoice = UiHost::UpdateChoice::Later; +}; + +/* + * QML implementation of UiHost: a QEventLoop stands in for QDialog::exec(), + * and QmlUiRequest stands in for the widget dialogs (BlockedModsDialog, + * UntrustedModsDialog, UpdateAvailableDialog, CustomMessageBox) that + * previously answered these questions. + * + * Each override below builds a QmlUiRequest describing the question, + * publishes it as `current`, and blocks in runRequest()'s local QEventLoop + * until QML answers it (a Q_INVOKABLE on the request, which emits + * answered()) or the request is cancelled -- explicitly, by the application + * quitting, or by this object itself being destroyed while the request is + * still outstanding (see runRequest() and the destructor). The request + * lives on the call's own stack, exactly as long as it is `current`; + * nothing here ever hands it to QML with any ownership but CppOwnership, so + * the QML engine never tries to delete it out from under us. + * + * RE-ENTRANCY: a call reached while another is already pending -- a + * background task asking a question while the user is still looking at an + * earlier one, say -- stacks rather than replacing what is already showing. + * `current` always names the innermost (most recently asked) request, and + * QML can only ever answer that one: answering it pops it and its call + * returns to whichever call it interrupted, which is what makes `current` + * point back at that older request again. This falls out of every request + * being an ordinary nested C++ call with its own QEventLoop -- there is no + * separate queue for requests to get out of order in. + * + * BUSY nests the same way: `busy` is true and `busyText` names the most + * recently started text for as long as any BusyIndicator this object handed + * out is still alive, in whatever order they end up being destroyed. + * + * QUIT / DESTRUCTION: aboutToQuit and the destructor both reject() every + * request still on the stack, so that a call blocked in runRequest() never + * outlives the application or this object -- it unwinds and returns the + * same answer a real "no" would. A request answered this way is read back + * from its own stack frame regardless of whether this object still exists; + * runRequest() itself only touches `this` again through a QPointer guard, + * so resuming after this object is gone updates nothing and crashes + * nothing. + * + * PRESENTER READINESS: this object exists (and is reachable through + * QmlShell::uiHost()) from the moment QmlShell::show() creates it, which is + * before the QML engine has even loaded Main.qml, let alone before whatever + * item there binds to `current` and can actually show a request. A UiHost + * call reached in that window -- an automatic startup update check finding + * no updater binary is the one that actually happened -- would otherwise + * wait forever for an answer nobody could give. So Application::uiHost() + * (via QmlShell::uiHostInterface()) only routes to this object once + * `presenterReady` is true, falling back to the widget host until then; the + * QML item that shows `current` calls setPresenterReady(true) once it is + * live (e.g. Component.onCompleted), the same way it will one day call + * setPresenterReady(false) if it is ever torn down first. + */ +class QmlUiHost : public QObject, public UiHost +{ + Q_OBJECT + + /// The innermost pending request, or null. CppOwnership; see the class + /// comment. + Q_PROPERTY(QObject* current READ current NOTIFY currentChanged) + Q_PROPERTY(bool busy READ busy NOTIFY busyChanged) + /// Text of the most recently started still-active showBusy() call, or + /// empty when busy is false. + Q_PROPERTY(QString busyText READ busyText NOTIFY busyChanged) + /* Whether some QML item is actually watching `current` and able to + * answer a request -- see the class comment's PRESENTER READINESS + * section. False until that item calls setPresenterReady(true). */ + Q_PROPERTY(bool presenterReady READ presenterReady WRITE setPresenterReady + NOTIFY presenterReadyChanged) + + public: + explicit QmlUiHost(QObject* parent = nullptr); + ~QmlUiHost() override; + + std::unique_ptr showBusy(const QString& text) override; + + void message(const QString& title, const QString& text, + Severity severity) override; + + bool confirm(const QString& title, const QString& text, + Severity severity, const QString& acceptLabel = QString(), + const QString& rejectLabel = QString()) override; + + int choose(const QString& title, const QString& text, Severity severity, + const QStringList& actions) override; + + std::optional askText( + const QString& title, const QString& text, + const QString& defaultValue = QString()) override; + + bool resolveBlockedMods(const QString& title, const QString& text, + QList& mods) override; + + bool confirmUntrustedMods(const QStringList& suspectPaths) override; + + UpdateChoice offerUpdate(const QString& currentVersion, + const QString& availableVersion, + const QString& releaseNotes) override; + + bool setupProfile(MinecraftAccountPtr account) override; + + std::optional pickFile(FilePickerMode mode, const QString& title, + const QString& defaultPath, + const QString& filter) override; + + QObject* current() const; + bool busy() const; + QString busyText() const; + bool presenterReady() const; + /// Called by the QML item that shows `current` -- see PRESENTER + /// READINESS in the class comment. Application/QmlShell read this + /// through uiHostInterface() to decide whether this object is safe to + /// route UiHost calls to yet. + Q_INVOKABLE void setPresenterReady(bool ready); + + /* Called by the BusyIndicator showBusy() hands out, from its + * destructor (a QPointer guards against this object already being + * gone). Not meant for any other caller. */ + void endBusy(int id); + + signals: + void currentChanged(); + void busyChanged(); + void presenterReadyChanged(); + + private: + /* Publishes @p request as `current`, blocks until it is answered or + * cancelled, then un-publishes it. See the class comment for the + * re-entrancy and quit/destruction rules this implements. */ + void runRequest(QmlUiRequest& request); + + QList m_stack; + bool m_presenterReady = false; + + struct BusyEntry { + int id; + QString text; + }; + QList m_busy; + int m_nextBusyId = 0; +}; diff --git a/launcher/qml/QmlUiHost_test.cpp b/launcher/qml/QmlUiHost_test.cpp new file mode 100644 index 00000000..8d50cf67 --- /dev/null +++ b/launcher/qml/QmlUiHost_test.cpp @@ -0,0 +1,334 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include + +#include +#include +#include + +#include "qml/QmlUiHost.h" + +/* + * Drives QmlUiHost the way QML would, without any QML: confirm()/etc. block + * in their own QEventLoop, so every test here schedules the "QML" side of + * the conversation on a zero-delay QTimer before making the blocking call -- + * the timer fires once that call's internal loop starts pumping events, the + * same trick used to test QDialog::exec(). + */ +class QmlUiHostTest : public QObject +{ + Q_OBJECT + + private slots: + void confirmReturnsTrueWhenQmlAccepts() + { + QmlUiHost host; + QVERIFY(!host.current()); + + QTimer::singleShot(0, [&host]() { + auto* request = qobject_cast(host.current()); + QVERIFY(request); + QCOMPARE(request->kind(), QStringLiteral("confirm")); + QCOMPARE(request->title(), QStringLiteral("Title")); + QCOMPARE(request->text(), QStringLiteral("Text")); + QCOMPARE(request->severity(), QStringLiteral("question")); + request->accept(); + }); + + const bool accepted = + host.confirm(QStringLiteral("Title"), QStringLiteral("Text"), + UiHost::Severity::Question); + + QVERIFY(accepted); + QVERIFY(!host.current()); + } + + void confirmReturnsFalseWhenQmlRejects() + { + QmlUiHost host; + + QTimer::singleShot(0, [&host]() { + auto* request = qobject_cast(host.current()); + QVERIFY(request); + request->reject(); + }); + + const bool accepted = + host.confirm(QStringLiteral("Title"), QStringLiteral("Text"), + UiHost::Severity::Warning); + + QVERIFY(!accepted); + } + + void chooseReturnsTheChosenIndex() + { + QmlUiHost host; + const QStringList actions{ QStringLiteral("Keep"), + QStringLiteral("Replace") }; + + QTimer::singleShot(0, [&host]() { + auto* request = qobject_cast(host.current()); + QVERIFY(request); + QCOMPARE(request->kind(), QStringLiteral("choose")); + QCOMPARE(request->actions(), QStringList({ QStringLiteral("Keep"), + QStringLiteral("Replace") })); + request->choose(1); + }); + + const int chosen = host.choose(QStringLiteral("Title"), + QStringLiteral("Text"), + UiHost::Severity::Question, actions); + + QCOMPARE(chosen, 1); + } + + void askTextReturnsTheEnteredValueWhenQmlAccepts() + { + QmlUiHost host; + + QTimer::singleShot(0, [&host]() { + auto* request = qobject_cast(host.current()); + QVERIFY(request); + QCOMPARE(request->kind(), QStringLiteral("text")); + QCOMPARE(request->value(), QStringLiteral("User")); + request->accept(QStringLiteral("Steve")); + }); + + const auto answer = + host.askText(QStringLiteral("Title"), QStringLiteral("Text"), + QStringLiteral("User")); + + QVERIFY(answer.has_value()); + QCOMPARE(*answer, QStringLiteral("Steve")); + } + + void askTextReturnsNulloptWhenQmlRejects() + { + QmlUiHost host; + + QTimer::singleShot(0, [&host]() { + auto* request = qobject_cast(host.current()); + QVERIFY(request); + request->reject(); + }); + + const auto answer = + host.askText(QStringLiteral("Title"), QStringLiteral("Text")); + + QVERIFY(!answer.has_value()); + } + + void confirmIsCancelledWhenTheApplicationQuits() + { + QmlUiHost host; + bool accepted = true; + + /* qApp->quit() only unwinds a QEventLoop while a top-level + * QCoreApplication::exec() is actually running somewhere on the + * stack -- true of the real launcher (see main.cpp) but not of a + * QTest slot, which never calls exec() itself. This test supplies + * that outer loop so it exercises the same shape runRequest() + * relies on in production, rather than the call it makes to + * qApp->quit() silently doing nothing. */ + QTimer::singleShot(0, [&]() { + QTimer::singleShot(0, [&host]() { + QVERIFY(host.current()); + qApp->quit(); + }); + accepted = host.confirm(QStringLiteral("Title"), + QStringLiteral("Text"), + UiHost::Severity::Question); + }); + qApp->exec(); + + QVERIFY(!accepted); + QVERIFY(!host.current()); + } + + void confirmIsCancelledWhenTheHostIsDestroyedWhilePending() + { + auto host = std::make_unique(); + + QTimer::singleShot(0, [&host]() { + QVERIFY(host->current()); + host.reset(); // destroys the host while confirm() below is blocked + }); + + const bool accepted = host->confirm(QStringLiteral("Title"), + QStringLiteral("Text"), + UiHost::Severity::Question); + + QVERIFY(!accepted); + QVERIFY(!host); + } + + void presenterReadyDefaultsToFalseAndNotifies() + { + QmlUiHost host; + QVERIFY(!host.presenterReady()); + + QSignalSpy spy(&host, &QmlUiHost::presenterReadyChanged); + host.setPresenterReady(true); + QVERIFY(host.presenterReady()); + QCOMPARE(spy.count(), 1); + + // No-op: same value, no redundant notification. + host.setPresenterReady(true); + QCOMPARE(spy.count(), 1); + } + + void checkProfileNameRejectsAnInvalidShapeWithoutAskingTheNetwork() + { + QmlUiRequest request(QmlUiRequest::Kind::ProfileSetup, + QStringLiteral("Title"), QStringLiteral("Text")); + QSignalSpy checkSpy(&request, &QmlUiRequest::checkNameRequested); + + request.checkProfileName(QStringLiteral("ab")); // too short + + QCOMPARE(request.profileNameStatus(), QStringLiteral("unset")); + QVERIFY(!request.profileNameError().isEmpty()); + QCOMPARE(checkSpy.count(), 0); + } + + void checkProfileNameAsksTheNetworkForAValidShape() + { + QmlUiRequest request(QmlUiRequest::Kind::ProfileSetup, + QStringLiteral("Title"), QStringLiteral("Text")); + QSignalSpy checkSpy(&request, &QmlUiRequest::checkNameRequested); + + request.checkProfileName(QStringLiteral("Steve")); + + QCOMPARE(request.profileNameStatus(), QStringLiteral("pending")); + QCOMPARE(checkSpy.count(), 1); + QCOMPARE(checkSpy.at(0).at(0).toString(), QStringLiteral("Steve")); + } + + void submitProfileNameDoesNothingUntilTheNameIsAvailable() + { + QmlUiRequest request(QmlUiRequest::Kind::ProfileSetup, + QStringLiteral("Title"), QStringLiteral("Text")); + QSignalSpy submitSpy(&request, &QmlUiRequest::submitNameRequested); + + request.submitProfileName(QStringLiteral("Steve")); // still "unset" + + QCOMPARE(submitSpy.count(), 0); + QVERIFY(!request.profileSubmitting()); + } + + void submitProfileNameAsksTheNetworkOnceAvailable() + { + QmlUiRequest request(QmlUiRequest::Kind::ProfileSetup, + QStringLiteral("Title"), QStringLiteral("Text")); + request.setProfileNameStatus(QStringLiteral("available"), QString()); + QSignalSpy submitSpy(&request, &QmlUiRequest::submitNameRequested); + + request.submitProfileName(QStringLiteral("Steve")); + + QCOMPARE(submitSpy.count(), 1); + QVERIFY(request.profileSubmitting()); + + // Re-entrant clicks while a submission is already in flight are + // ignored rather than starting a second one. + request.submitProfileName(QStringLiteral("Steve")); + QCOMPARE(submitSpy.count(), 1); + } + + void setupProfileReturnsFalseWhenQmlRejects() + { + QmlUiHost host; + + QTimer::singleShot(0, [&host]() { + auto* request = qobject_cast(host.current()); + QVERIFY(request); + QCOMPARE(request->kind(), QStringLiteral("profileSetup")); + request->reject(); + }); + + const bool created = host.setupProfile(nullptr); + + QVERIFY(!created); + QVERIFY(!host.current()); + } + + void pickFileReturnsNulloptWhenQmlRejects() + { + QmlUiHost host; + + QTimer::singleShot(0, [&host]() { + auto* request = qobject_cast(host.current()); + QVERIFY(request); + QCOMPARE(request->kind(), QStringLiteral("filePicker")); + request->reject(); + }); + + const auto result = + host.pickFile(UiHost::FilePickerMode::Open, QStringLiteral("Title"), + QString(), QStringLiteral("*.txt")); + + QVERIFY(!result.has_value()); + } + + void pickFileUnwrapsAFileUrlToALocalPath() + { + QmlUiHost host; + + QTimer::singleShot(0, [&host]() { + auto* request = qobject_cast(host.current()); + QVERIFY(request); + QCOMPARE(request->filePickerMode(), QStringLiteral("save")); + request->accept(QStringLiteral("file:///tmp/example.txt")); + }); + + const auto result = + host.pickFile(UiHost::FilePickerMode::Save, QStringLiteral("Title"), + QStringLiteral("example.txt"), QString()); + + QVERIFY(result.has_value()); + QCOMPARE(*result, QStringLiteral("/tmp/example.txt")); + } + + void busyNestsAndReportsTheInnermostText() + { + QmlUiHost host; + QVERIFY(!host.busy()); + QVERIFY(host.busyText().isEmpty()); + + auto outer = host.showBusy(QStringLiteral("Loading")); + QVERIFY(host.busy()); + QCOMPARE(host.busyText(), QStringLiteral("Loading")); + + { + auto inner = host.showBusy(QStringLiteral("Downloading")); + QVERIFY(host.busy()); + QCOMPARE(host.busyText(), QStringLiteral("Downloading")); + } + // `inner` destroyed above; the outer indicator is still alive. + QVERIFY(host.busy()); + QCOMPARE(host.busyText(), QStringLiteral("Loading")); + + outer.reset(); + QVERIFY(!host.busy()); + QVERIFY(host.busyText().isEmpty()); + } +}; + +QTEST_GUILESS_MAIN(QmlUiHostTest) + +#include "QmlUiHost_test.moc" diff --git a/launcher/qml/ScreenshotThumbnailProvider.cpp b/launcher/qml/ScreenshotThumbnailProvider.cpp new file mode 100644 index 00000000..4ef652ba --- /dev/null +++ b/launcher/qml/ScreenshotThumbnailProvider.cpp @@ -0,0 +1,220 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "ScreenshotThumbnailProvider.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace +{ +// Only a fallback for an invalid/empty requestedSize; mirrors +// InstanceIconProvider's/AccountFaceProvider's own default-extent fallback. +constexpr int kDefaultThumbnailExtent = 256; + +QSize normalizedBox(const QSize& requestedSize) +{ + return requestedSize.isValid() && !requestedSize.isEmpty() + ? requestedSize + : QSize(kDefaultThumbnailExtent, kDefaultThumbnailExtent); +} + +QString cacheKeyFor(const QString& path, qint64 mtimeMs, const QSize& box) +{ + return QStringLiteral("%1@%2@%3x%4") + .arg(path) + .arg(mtimeMs) + .arg(box.width()) + .arg(box.height()); +} +} // namespace + +/* Thread-safe wrapper around QCache: QCache itself keeps + * no locking of its own, but worker threads from the provider's QThreadPool + * may look up and insert concurrently. Capacity is a plain entry count + * (QCache's default per-item cost of 1), not a byte budget -- simple, and + * "small" here only needs to be enough that scrolling a grid back up hits + * cache rather than re-decoding, not a hard memory ceiling. */ +class ScreenshotThumbnailCache +{ + public: + bool get(const QString& key, QImage& out) + { + QMutexLocker lock(&m_mutex); + if (const QImage* hit = m_cache.object(key)) { + out = *hit; + return true; + } + return false; + } + + void insert(const QString& key, const QImage& image) + { + QMutexLocker lock(&m_mutex); + m_cache.insert(key, new QImage(image)); + } + + private: + static constexpr int kMaxEntries = 200; + QMutex m_mutex; + QCache m_cache{kMaxEntries}; +}; + +namespace +{ +/* Runs entirely on a QThreadPool worker thread: stats the file, checks the + * cache, and decodes + scales on a miss. Never touches GUI-thread-only + * types (QImage is fine off-thread; nothing here is a QPixmap/QIcon). */ +class ScreenshotThumbnailRunnable : public QObject, public QRunnable +{ + Q_OBJECT + public: + ScreenshotThumbnailRunnable( + QString path, QSize requestedSize, + std::shared_ptr cache) + : m_path(std::move(path)), m_requestedSize(requestedSize), + m_cache(std::move(cache)) + { + } + + void run() override + { + const QSize box = normalizedBox(m_requestedSize); + + const QFileInfo info(m_path); + if (!info.exists() || !info.isFile()) { + emit done(QImage(), + QStringLiteral("screenshot not found: %1").arg(m_path)); + return; + } + + const qint64 mtimeMs = info.lastModified().toMSecsSinceEpoch(); + const QString key = cacheKeyFor(m_path, mtimeMs, box); + + QImage cached; + if (m_cache->get(key, cached)) { + emit done(cached, QString()); + return; + } + + QImage image(m_path); + if (image.isNull()) { + emit done(QImage(), QStringLiteral("could not decode image: %1") + .arg(m_path)); + return; + } + + const QImage scaled = image.scaled(box, Qt::KeepAspectRatio, + Qt::SmoothTransformation); + m_cache->insert(key, scaled); + emit done(scaled, QString()); + } + + signals: + // Queued across to the response living on the thread that created it; + // see ScreenshotImageResponse's constructor. + void done(QImage image, QString error); + + private: + QString m_path; + QSize m_requestedSize; + std::shared_ptr m_cache; +}; + +class ScreenshotImageResponse : public QQuickImageResponse +{ + Q_OBJECT + public: + ScreenshotImageResponse(const QString& path, const QSize& requestedSize, + std::shared_ptr cache, + QThreadPool* pool) + { + auto* runnable = new ScreenshotThumbnailRunnable( + path, requestedSize, std::move(cache)); + // Default (true) autoDelete is fine: the queued connection below + // copies its arguments into an event before run() returns, so the + // pool deleting the runnable right after has nothing left to race. + connect(runnable, &ScreenshotThumbnailRunnable::done, this, + &ScreenshotImageResponse::handleDone, Qt::QueuedConnection); + pool->start(runnable); + } + + QQuickTextureFactory* textureFactory() const override + { + return m_image.isNull() + ? nullptr + : QQuickTextureFactory::textureFactoryForImage(m_image); + } + + QString errorString() const override + { + return m_error; + } + + private slots: + void handleDone(QImage image, QString error) + { + m_image = std::move(image); + m_error = std::move(error); + emit finished(); + } + + private: + QImage m_image; + QString m_error; +}; +} // namespace + +ScreenshotThumbnailProvider::ScreenshotThumbnailProvider() + : m_cache(std::make_shared()) +{ + // A handful of threads is plenty for decoding+scaling screenshot + // thumbnails -- mirrors the widget page's own ThumbnailRunnable pool + // (max 4, see ScreenshotsPage.cpp) rather than contending with + // QThreadPool::globalInstance(), which other subsystems already share. + m_pool.setMaxThreadCount(4); +} + +ScreenshotThumbnailProvider::~ScreenshotThumbnailProvider() +{ + // Same reasoning as FilterModel's destructor in ScreenshotsPage.cpp: + // give in-flight work a chance to finish rather than destroying the + // pool out from under running QRunnables. + m_pool.waitForDone(500); +} + +QQuickImageResponse* ScreenshotThumbnailProvider::requestImageResponse( + const QString& id, const QSize& requestedSize) +{ + // See the header for why this is safe even if Qt Quick's own URL + // handling already undid some of the percent-encoding before id + // reached here. + const QString path = QUrl::fromPercentEncoding(id.toUtf8()); + return new ScreenshotImageResponse(path, requestedSize, m_cache, &m_pool); +} + +#include "ScreenshotThumbnailProvider.moc" diff --git a/launcher/qml/ScreenshotThumbnailProvider.h b/launcher/qml/ScreenshotThumbnailProvider.h new file mode 100644 index 00000000..055f8c0b --- /dev/null +++ b/launcher/qml/ScreenshotThumbnailProvider.h @@ -0,0 +1,94 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include + +#include + +class ScreenshotThumbnailCache; + +/* + * Decodes and scales screenshot thumbnails for QML, off the GUI thread. A + * delegate writes + * + * Image { source: "image://screenshot/" + encodeURIComponent(path) } + * + * where `path` is the absolute file path -- e.g. the `path` role of + * ScreenshotListModel (see screenshots/ScreenshotListModel.h). encodeURIComponent + * is plain QML/JS, available in any binding or delegate; it turns whatever + * the OS allows in a path (spaces, '#', non-ASCII, ...) into a string that + * survives being parsed as a URL. requestImageResponse() below reverses + * exactly that with QUrl::fromPercentEncoding(), which is a no-op on a + * string that has nothing left to decode -- safe either way, whether or not + * Qt Quick's own URL handling has already undone some of the encoding by + * the time `id` reaches this provider. + * + * THREADING. Unlike InstanceIconProvider/AccountFaceProvider (Pixmap-type, + * GUI-thread-only -- see InstanceIconProvider.h for why), this is an async + * provider: requestImageResponse() only creates and returns a + * QQuickImageResponse, whose finished() signal QML waits on; the actual + * QImage decode + scale runs on this provider's own QThreadPool (separate + * from the global pool, so screenshot thumbnails never queue behind + * unrelated work), on a worker QRunnable that hands its result back across + * threads with a Qt::QueuedConnection. QImage (unlike QPixmap) is safe to + * build off the GUI thread, which is what makes this split possible at all. + * + * SIZE. `requestedSize` is the box QML wants the thumbnail to fit inside, + * already in device pixels. An invalid/empty one (QML did not set + * sourceSize) falls back to a 256x256 box. Either way the source image is + * scaled with Qt::KeepAspectRatio, so the longer side lands on the box's + * matching dimension and the image is never stretched or cropped -- unlike + * ScreenshotsPage.cpp's ThumbnailRunnable, which pads to a centered 256x256 + * square. Callers that want a uniform grid cell handle that in the QML + * delegate (Image.fillMode: PreserveAspectFit/Crop), not here. + * + * CACHE. A small in-memory LRU, keyed by path + the file's mtime + the + * requested box size, so re-showing the same thumbnail at the same size + * (scrolling back up a grid) is instant and never re-decodes the PNG/JPEG. + * The mtime in the key means a screenshot overwritten with new content + * (same path) is simply a cache miss, not stale data -- no invalidation + * logic needed. Shared (via std::shared_ptr) between the provider and every + * in-flight worker rather than owned solely by the provider, so a response + * that outlives the provider (e.g. during shutdown) never touches a freed + * cache. + * + * MISSING/UNREADABLE FILES. Handled without throwing or crashing: a + * response for a path that does not exist, is not a file, or fails to + * decode as an image comes back with a null QQuickTextureFactory and a + * non-empty errorString() -- QML sees Image.status == Image.Error, not a + * broken provider. + */ +class ScreenshotThumbnailProvider : public QQuickAsyncImageProvider +{ + public: + ScreenshotThumbnailProvider(); + ~ScreenshotThumbnailProvider() override; + + QQuickImageResponse* requestImageResponse( + const QString& id, const QSize& requestedSize) override; + + private: + QThreadPool m_pool; + std::shared_ptr m_cache; +}; diff --git a/launcher/qml/ScreenshotThumbnailProvider_test.cpp b/launcher/qml/ScreenshotThumbnailProvider_test.cpp new file mode 100644 index 00000000..0565e974 --- /dev/null +++ b/launcher/qml/ScreenshotThumbnailProvider_test.cpp @@ -0,0 +1,176 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "ScreenshotThumbnailProvider.h" + +namespace +{ +QString makePng(const QString& dir, const QString& name, const QSize& size) +{ + QImage image(size, QImage::Format_ARGB32); + image.fill(Qt::red); + const QString path = QDir(dir).filePath(name); + return image.save(path, "PNG") ? path : QString(); +} + +// Mirrors the QML-side encoding this provider expects -- see the class +// comment on ScreenshotThumbnailProvider for why encode/decode round-trip. +QString encodeAsId(const QString& path) +{ + return QString::fromUtf8(QUrl::toPercentEncoding(path)); +} +} // namespace + +/* + * Unit test for ScreenshotThumbnailProvider. Drives requestImageResponse() + * directly rather than through a QQmlEngine -- QQuickAsyncImageProvider + * needs nothing but a QGuiApplication to construct and run its worker, and + * the response's finished() signal is exactly what QML itself waits on. + */ +class ScreenshotThumbnailProviderTest : public QObject +{ + Q_OBJECT + + private slots: + void decodesAndScalesKeepingAspectRatio() + { + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + const QString path = + makePng(tempDir.path(), "wide.png", QSize(800, 400)); + QVERIFY(!path.isEmpty()); + + ScreenshotThumbnailProvider provider; + std::unique_ptr response( + provider.requestImageResponse(encodeAsId(path), QSize(200, 200))); + QVERIFY(response != nullptr); + + QSignalSpy finished(response.get(), &QQuickImageResponse::finished); + QVERIFY(finished.wait(5000)); + + QCOMPARE(response->errorString(), QString()); + std::unique_ptr factory( + response->textureFactory()); + QVERIFY(factory != nullptr); + // 800x400 kept-aspect into a 200x200 box: width fills the box, + // height follows the 2:1 ratio -- never square-padded. + QCOMPARE(factory->textureSize(), QSize(200, 100)); + } + + void repeatedRequestServesFromCache() + { + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + const QString path = + makePng(tempDir.path(), "square.png", QSize(600, 600)); + QVERIFY(!path.isEmpty()); + + ScreenshotThumbnailProvider provider; + + std::unique_ptr first( + provider.requestImageResponse(encodeAsId(path), QSize(128, 128))); + QSignalSpy firstFinished(first.get(), &QQuickImageResponse::finished); + QVERIFY(firstFinished.wait(5000)); + std::unique_ptr firstFactory( + first->textureFactory()); + QVERIFY(firstFactory != nullptr); + QCOMPARE(firstFactory->textureSize(), QSize(128, 128)); + + // Same path, same mtime, same requested box: a second request + // should still succeed (whether served from cache or decoded + // again is an implementation detail -- the observable contract is + // just that it returns the same, correct result). + std::unique_ptr second( + provider.requestImageResponse(encodeAsId(path), QSize(128, 128))); + QSignalSpy secondFinished(second.get(), + &QQuickImageResponse::finished); + QVERIFY(secondFinished.wait(5000)); + std::unique_ptr secondFactory( + second->textureFactory()); + QVERIFY(secondFactory != nullptr); + QCOMPARE(secondFactory->textureSize(), QSize(128, 128)); + } + + void missingFileReportsErrorWithoutCrashing() + { + ScreenshotThumbnailProvider provider; + std::unique_ptr response( + provider.requestImageResponse( + encodeAsId(QStringLiteral("/no/such/screenshot.png")), + QSize(128, 128))); + QVERIFY(response != nullptr); + + QSignalSpy finished(response.get(), &QQuickImageResponse::finished); + QVERIFY(finished.wait(5000)); + + QVERIFY(!response->errorString().isEmpty()); + QVERIFY(response->textureFactory() == nullptr); + } + + void defaultSizeIsUsedWhenNoneRequested() + { + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + const QString path = + makePng(tempDir.path(), "large.png", QSize(1024, 512)); + QVERIFY(!path.isEmpty()); + + ScreenshotThumbnailProvider provider; + std::unique_ptr response( + provider.requestImageResponse(encodeAsId(path), QSize())); + QVERIFY(response != nullptr); + + QSignalSpy finished(response.get(), &QQuickImageResponse::finished); + QVERIFY(finished.wait(5000)); + + std::unique_ptr factory( + response->textureFactory()); + QVERIFY(factory != nullptr); + // 1024x512 (2:1) fit into the default 256x256 box. + QCOMPARE(factory->textureSize(), QSize(256, 128)); + } +}; + +int main(int argc, char* argv[]) +{ + // Same reasoning as InstanceIconProvider_test.cpp/ + // AccountFaceProvider_test.cpp: QQuickTextureFactory needs a + // QGuiApplication, and forcing offscreen keeps this runnable on a + // headless runner. + qputenv("QT_QPA_PLATFORM", "offscreen"); + + QGuiApplication app(argc, argv); + + ScreenshotThumbnailProviderTest test; + return QTest::qExec(&test, argc, argv); +} + +#include "ScreenshotThumbnailProvider_test.moc" diff --git a/launcher/qml/Style/BusyIndicator.qml b/launcher/qml/Style/BusyIndicator.qml new file mode 100644 index 00000000..1108e7c7 --- /dev/null +++ b/launcher/qml/Style/BusyIndicator.qml @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Templates as T +import MeshMC.Theme + +// Basic's spinner comes from the same private Basic.impl helper that +// ProgressBar.qml avoids for the same reason (see its comment); this draws a +// single rounded arc on a Canvas and spins the whole item with a +// RotationAnimation, which needs nothing beyond QtQuick. +T.BusyIndicator { + id: control + + implicitWidth: Math.max(implicitBackgroundWidth + leftInset + rightInset, + implicitContentWidth + leftPadding + rightPadding) + implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset, + implicitContentHeight + topPadding + bottomPadding) + + padding: Theme.space.sm + + contentItem: Canvas { + id: canvas + implicitWidth: Theme.control.height + implicitHeight: Theme.control.height + + // Canvas painting is imperative, not a binding, so a theme swap + // (light/dark) needs an explicit repaint trigger -- this property + // exists only to give onPaint's colour a dependency to react to. + property color strokeColor: Theme.palette.accent + onStrokeColorChanged: requestPaint() + + opacity: control.running ? 1 : 0 + Behavior on opacity { + OpacityAnimator { duration: Theme.motion.normal } + } + + RotationAnimation on rotation { + running: control.running + loops: Animation.Infinite + from: 0 + to: 360 + duration: 900 + } + + onPaint: { + const ctx = getContext("2d"); + ctx.reset(); + const lineWidth = Math.max(2, width * 0.1); + const radius = (width - lineWidth) / 2; + ctx.lineWidth = lineWidth; + ctx.lineCap = "round"; + ctx.strokeStyle = canvas.strokeColor; + ctx.beginPath(); + // Three quarters of a turn: enough to read as a ring without + // looking like a closed, "finished" circle. + ctx.arc(width / 2, height / 2, radius, 0, Math.PI * 1.5); + ctx.stroke(); + } + + Component.onCompleted: requestPaint() + } +} diff --git a/launcher/qml/Style/Button.qml b/launcher/qml/Style/Button.qml new file mode 100644 index 00000000..0ea193e3 --- /dev/null +++ b/launcher/qml/Style/Button.qml @@ -0,0 +1,134 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls.impl +import QtQuick.Templates as T +import MeshMC.Theme + +// Three variants come out of T.Button's existing `flat`/`highlighted` pair +// rather than a custom `variant` enum, so call sites keep using the stock +// Qt Quick Controls API: +// highlighted -> accent-filled primary action +// flat -> transparent, only a hover/press overlay +// neither (the default) -> a raised neutral surface +// `highlighted` wins if both are set, since "primary" and "quiet" is a +// contradiction a design system should resolve rather than render. +// +// Contract: at most one `highlighted` button per screen (design-plan.md +// Principle 1) -- it is the one full accent fill a page is allowed. Every +// other actionable control on that screen uses the default (raised neutral +// surface + hairline border) or `flat` variant instead. This is a call-site +// discipline, not something this file can enforce by itself; the default +// variant already reads as a real control (surfaceRaised fill, border, +// hover/press overlay below) precisely so call sites have a non-accent option +// that still looks pressable. +T.Button { + id: control + + // A highlighted button whose action is destructive (delete, remove) + // reads as danger-red instead of accent-cyan; it has no effect unless + // `highlighted` is also set, same as `flat` only matters unhighlighted. + property bool danger: false + + implicitWidth: Math.max(implicitBackgroundWidth + leftInset + rightInset, + implicitContentWidth + leftPadding + rightPadding) + implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset, + implicitContentHeight + topPadding + bottomPadding) + + horizontalPadding: Theme.space.lg + // An icon-only button is square: the icon sits in a box as wide as the + // control is tall, not in a wide text-button slot. + leftPadding: display === T.AbstractButton.IconOnly ? Theme.space.sm : horizontalPadding + rightPadding: display === T.AbstractButton.IconOnly ? Theme.space.sm : horizontalPadding + spacing: Theme.space.sm + + icon.width: Theme.icon.sm + icon.height: Theme.icon.sm + icon.color: contentColor + + // Fades as one piece, so a disabled accent button still reads as the + // same button rather than a grey one. + opacity: enabled ? 1.0 : Theme.opacity.disabled + + // The same colour drives the icon and the label so an accent button never + // ends up with mismatched icon/text tones. + readonly property color contentColor: control.highlighted + ? Theme.palette.textOnAccent + : control.flat && !control.hovered ? Theme.palette.textSecondary + : Theme.palette.textPrimary + + // The accent-filled variant gets its own hover/press colours + // (accentHover/accentPressed) instead of the generic overlay tokens: + // laying a translucent hoverOverlay on top of an already-saturated + // accent fill would shift its hue/muddy it, where the dedicated tokens + // are tuned to stay on-brand. Neutral surfaces (default/flat) have no + // such dedicated variant, so they use the overlay tokens instead. + readonly property color restColor: control.highlighted + ? (control.danger + ? (control.down ? Qt.darker(Theme.palette.danger, 1.2) : control.hovered ? Qt.lighter(Theme.palette.danger, 1.12) : Theme.palette.danger) + : (control.down ? Theme.palette.accentPressed : control.hovered ? Theme.palette.accentHover : Theme.palette.accent)) + : (control.flat ? "transparent" : Theme.palette.surfaceRaised) + readonly property color overlayColor: control.highlighted + ? "transparent" + : (control.down ? Theme.palette.pressedOverlay : control.hovered ? Theme.palette.hoverOverlay : "transparent") + + contentItem: IconLabel { + spacing: control.spacing + mirrored: control.mirrored + display: control.display + + icon: control.icon + text: control.text + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + 1 + font.weight: control.highlighted ? Font.DemiBold : Font.Medium + color: control.contentColor + } + + background: Rectangle { + id: background + implicitWidth: Theme.control.height + implicitHeight: Theme.control.height + radius: Theme.radius.md + color: control.restColor + // Flat buttons only ever show the hover/press overlay, so the border + // stays off for them too -- a border would make "flat" look like a + // fourth, unrequested variant. + border.width: !control.highlighted && !control.flat ? 1 : 0 + border.color: control.hovered ? Theme.palette.borderStrong : Theme.palette.border + + Behavior on color { + ColorAnimation { duration: Theme.motion.fast; easing.type: Theme.motion.easing } + } + + Rectangle { + anchors.fill: parent + radius: parent.radius + color: control.overlayColor + Behavior on color { + ColorAnimation { duration: Theme.motion.fast; easing.type: Theme.motion.easing } + } + } + + // A faint light from above on the accent fill: gives the primary + // action some depth without a drop shadow. + Rectangle { + anchors.fill: parent + radius: parent.radius + visible: control.highlighted && !control.down + gradient: Gradient { + GradientStop { position: 0.0; color: Qt.rgba(1, 1, 1, 0.22) } + GradientStop { position: 0.55; color: Qt.rgba(1, 1, 1, 0.0) } + } + } + + FocusRing { + anchors.fill: parent + anchors.margins: -Theme.space.xxs + radius: parent.radius + Theme.space.xxs + visible: control.visualFocus + } + } +} diff --git a/launcher/qml/Style/CMakeLists.txt b/launcher/qml/Style/CMakeLists.txt new file mode 100644 index 00000000..166d21c7 --- /dev/null +++ b/launcher/qml/Style/CMakeLists.txt @@ -0,0 +1,73 @@ +# SPDX-FileCopyrightText: 2026 Project Tick +# SPDX-FileContributor: Project Tick +# SPDX-License-Identifier: Apache-2.0 + +######## MeshMC.Style: the launcher's Qt Quick Controls style ######## + +# A qmldir'd QML module, same shape as launcher/qml/CMakeLists.txt's +# MeshMC_qml: one CMakeLists per module directory, RESOURCE_PREFIX spelled out +# by hand rather than via qt_policy(QTP0001) because that policy needs Qt +# 6.5+ and this project's floor is 6.4. +# +# This module is never linked into MeshMC_qml or added to the app's style +# selection here -- both are the integrator's job, done once every style/theme +# module in the launcher exists. Building it as its own static library keeps +# it that way: nothing in this file can accidentally wire itself in. +qt_add_library(MeshMC_qml_style STATIC) + +qt_add_qml_module(MeshMC_qml_style + URI MeshMC.Style + VERSION 1.0 + RESOURCE_PREFIX "/qt/qml" + # QtQuick.Controls.Basic is the fallback: any control this style does not + # define (DialogButtonBox, RoundButton, Drawer...) resolves to Basic's + # instead of failing to load. Without it the style is only safe for the + # exact set of controls it happens to ship today. + IMPORTS + MeshMC.Theme + QtQuick.Controls.Basic + DEPENDENCIES + QtQuick.Templates + QML_FILES + BusyIndicator.qml + Button.qml + CheckBox.qml + CheckMark.qml + Chevron.qml + ComboBox.qml + Dialog.qml + FocusRing.qml + Frame.qml + ItemDelegate.qml + Label.qml + Menu.qml + MenuItem.qml + MenuSeparator.qml + Pane.qml + Popup.qml + PopupShadow.qml + ProgressBar.qml + RadioButton.qml + ScrollBar.qml + ScrollIndicator.qml + Slider.qml + SpinBox.qml + Switch.qml + TabBar.qml + TabButton.qml + TextArea.qml + TextField.qml + ToolButton.qml + ToolTip.qml +) + +# Style QML files reach into QtQuick.Templates (T.Button etc.) and +# QtQuick.Controls.impl (IconLabel/IconImage/ColorImage) directly; the +# corresponding C++ modules still need to be linked for those imports to +# resolve at runtime. QuickControls2 additionally pulls in the styling +# infrastructure that makes a custom style like this one selectable at all. +target_link_libraries(MeshMC_qml_style PUBLIC + Qt${QT_VERSION_MAJOR}::Quick + Qt${QT_VERSION_MAJOR}::QuickControls2 + Qt${QT_VERSION_MAJOR}::QuickTemplates2 +) diff --git a/launcher/qml/Style/CheckBox.qml b/launcher/qml/Style/CheckBox.qml new file mode 100644 index 00000000..9c9e548a --- /dev/null +++ b/launcher/qml/Style/CheckBox.qml @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Templates as T +import MeshMC.Theme + +T.CheckBox { + id: control + + implicitWidth: Math.max(implicitBackgroundWidth + leftInset + rightInset, + implicitContentWidth + leftPadding + rightPadding) + implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset, + implicitContentHeight + topPadding + bottomPadding, + implicitIndicatorHeight + topPadding + bottomPadding) + + padding: Theme.space.xs + spacing: Theme.space.sm + + indicator: Rectangle { + id: box + implicitWidth: Theme.icon.lg + implicitHeight: Theme.icon.lg + + x: control.text ? (control.mirrored ? control.width - width - control.rightPadding : control.leftPadding) : control.leftPadding + (control.availableWidth - width) / 2 + y: control.topPadding + (control.availableHeight - height) / 2 + + // Checked/partially-checked share the accent fill; only Unchecked is + // the plain outlined surface, so this one flag decides both the fill + // colour below and which of the two overlay/tint strategies applies. + readonly property bool boxChecked: control.checkState !== Qt.Unchecked + + radius: Theme.radius.sm + color: box.boxChecked + ? (control.down ? Theme.palette.accentPressed : control.hovered ? Theme.palette.accentHover : Theme.palette.accent) + : Theme.palette.surfaceRaised + border.width: box.boxChecked ? 0 : 1 + border.color: Theme.palette.border + opacity: control.enabled ? 1.0 : 0.45 + + Behavior on color { + ColorAnimation { duration: Theme.motion.fast; easing.type: Theme.motion.easing } + } + + Rectangle { + // The unchecked box has no dedicated hover/press colour, so it + // uses the generic overlay tokens instead; the checked (accent) + // box already darkens via accentHover/accentPressed above. + anchors.fill: parent + radius: parent.radius + visible: !box.boxChecked + color: control.down ? Theme.palette.pressedOverlay : control.hovered ? Theme.palette.hoverOverlay : "transparent" + } + + CheckMark { + anchors.fill: parent + anchors.margins: Theme.space.xxs + color: Theme.palette.textOnAccent + partial: control.checkState === Qt.PartiallyChecked + visible: box.boxChecked + } + + FocusRing { + anchors.fill: parent + anchors.margins: -Theme.space.xxs + radius: parent.radius + Theme.space.xxs + visible: control.visualFocus + } + } + + contentItem: Text { + leftPadding: control.indicator && !control.mirrored ? control.indicator.width + control.spacing : 0 + rightPadding: control.indicator && control.mirrored ? control.indicator.width + control.spacing : 0 + + text: control.text + font.family: Theme.font.family + font.pixelSize: Theme.type.body.pixelSize + font.weight: Theme.type.body.weight + color: control.enabled ? Theme.palette.textPrimary : Theme.palette.textDisabled + elide: Text.ElideRight + verticalAlignment: Text.AlignVCenter + } +} diff --git a/launcher/qml/Style/CheckMark.qml b/launcher/qml/Style/CheckMark.qml new file mode 100644 index 00000000..4052b371 --- /dev/null +++ b/launcher/qml/Style/CheckMark.qml @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick + +// CheckBox's checked state and MenuItem's checkable-and-checked state need +// the identical glyph. Basic's own check mark is a PNG baked into the Basic +// style plugin's resources, which this style does not link against, so the +// mark is drawn instead: a couple of Canvas strokes scale losslessly and pick +// up Theme colours directly, where a bitmap would need a re-export per size +// and per theme. +Canvas { + id: mark + + property color color: "black" + // Drives a short dash instead of the full tick, so CheckBox can reuse + // this one canvas for Qt.PartiallyChecked as well as Qt.Checked instead + // of keeping a second glyph around for one extra state. + property bool partial: false + + onColorChanged: requestPaint() + onPartialChanged: requestPaint() + onWidthChanged: requestPaint() + onHeightChanged: requestPaint() + + onPaint: { + const ctx = getContext("2d"); + ctx.reset(); + ctx.strokeStyle = color; + ctx.lineWidth = Math.max(1.5, width * 0.14); + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + ctx.beginPath(); + if (partial) { + ctx.moveTo(width * 0.22, height * 0.5); + ctx.lineTo(width * 0.78, height * 0.5); + } else { + ctx.moveTo(width * 0.2, height * 0.52); + ctx.lineTo(width * 0.42, height * 0.74); + ctx.lineTo(width * 0.8, height * 0.28); + } + ctx.stroke(); + } +} diff --git a/launcher/qml/Style/Chevron.qml b/launcher/qml/Style/Chevron.qml new file mode 100644 index 00000000..22271d83 --- /dev/null +++ b/launcher/qml/Style/Chevron.qml @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick + +// Shared by ComboBox (dropdown indicator) and MenuItem (submenu arrow), which +// otherwise wanted the same "v" glyph in two different rotations. Drawn on a +// Canvas for the same reason as CheckMark: no bitmap asset to re-export per +// theme/size, and it stays crisp at any of Theme.icon's sizes. +Canvas { + id: chevron + + property color color: "black" + // 0 = points down (ComboBox), 1 = points right (MenuItem submenu arrow). + property int direction: 0 + + onColorChanged: requestPaint() + onDirectionChanged: requestPaint() + onWidthChanged: requestPaint() + onHeightChanged: requestPaint() + + onPaint: { + const ctx = getContext("2d"); + ctx.reset(); + ctx.strokeStyle = color; + ctx.lineWidth = Math.max(1.5, width * 0.16); + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + ctx.beginPath(); + if (direction === 1) { + ctx.moveTo(width * 0.32, height * 0.2); + ctx.lineTo(width * 0.68, height * 0.5); + ctx.lineTo(width * 0.32, height * 0.8); + } else { + ctx.moveTo(width * 0.2, height * 0.36); + ctx.lineTo(width * 0.5, height * 0.66); + ctx.lineTo(width * 0.8, height * 0.36); + } + ctx.stroke(); + } +} diff --git a/launcher/qml/Style/ComboBox.qml b/launcher/qml/Style/ComboBox.qml new file mode 100644 index 00000000..a22c7194 --- /dev/null +++ b/launcher/qml/Style/ComboBox.qml @@ -0,0 +1,149 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Templates as T +import MeshMC.Theme + +T.ComboBox { + id: control + + implicitWidth: Math.max(implicitBackgroundWidth + leftInset + rightInset, + implicitContentWidth + leftPadding + rightPadding) + implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset, + implicitContentHeight + topPadding + bottomPadding, + implicitIndicatorHeight + topPadding + bottomPadding) + + leftPadding: Theme.space.sm + (!control.mirrored || !indicator || !indicator.visible ? 0 : indicator.width + spacing) + rightPadding: Theme.space.sm + (control.mirrored || !indicator || !indicator.visible ? 0 : indicator.width + spacing) + spacing: Theme.space.xs + + // This ComboBox uses our own ItemDelegate.qml (same directory, so no + // import needed) for popup rows instead of Basic's, otherwise every row + // would suddenly look unstyled next to a themed field. + delegate: ItemDelegate { + required property var model + required property int index + + width: ListView.view.width + text: model[control.textRole] + font.weight: control.currentIndex === index ? Theme.type.bodyStrong.weight : Theme.type.body.weight + highlighted: control.highlightedIndex === index + hoverEnabled: control.hoverEnabled + } + + indicator: Chevron { + // Theme.space.sm, not control.padding: leftPadding/rightPadding + // above are set explicitly (asymmetric, to leave room for this + // indicator), which leaves the generic `padding` itself at its + // unset default of 0 -- reading it here silently put the indicator + // flush against the control's own edge, right on top of its + // border, instead of inset by the margin rightPadding actually + // reserved for it. + x: control.mirrored ? Theme.space.sm : control.width - width - Theme.space.sm + y: control.topPadding + (control.availableHeight - height) / 2 + width: Theme.icon.sm + height: Theme.icon.sm + color: control.enabled ? Theme.palette.textSecondary : Theme.palette.textDisabled + direction: 0 + // A quiet nod to the popup's open/closed state instead of a second + // glyph: flipping the same chevron costs nothing and reads as "this + // is now showing its other side". + rotation: control.popup.visible ? 180 : 0 + Behavior on rotation { + NumberAnimation { duration: Theme.motion.fast; easing.type: Theme.motion.easing } + } + } + + contentItem: T.TextField { + leftPadding: 0 + rightPadding: 0 + topPadding: 0 + bottomPadding: 0 + + text: control.editable ? control.editText : control.displayText + + enabled: control.editable + autoScroll: control.editable + readOnly: control.down + inputMethodHints: control.inputMethodHints + validator: control.validator + selectByMouse: control.selectTextByMouse + + font.family: Theme.font.family + font.pixelSize: Theme.type.body.pixelSize + font.weight: Theme.type.body.weight + color: Theme.palette.textPrimary + selectionColor: Theme.palette.selection + selectedTextColor: Theme.palette.selectionText + verticalAlignment: Text.AlignVCenter + + background: Item {} + } + + background: Rectangle { + implicitWidth: Theme.control.heightLg * 4 + implicitHeight: Theme.control.height + radius: Theme.radius.md + color: control.down ? Theme.palette.pressedOverlay : Theme.palette.surfaceRaised + border.width: 1 + // The one control in the style with no hover feedback at all -- + // single property (border colour only, matching TextField.qml's + // idiom), not also brightening the fill, per Theme.motion's + // one-property hover contract. + border.color: (control.activeFocus || control.hovered) ? Theme.palette.borderStrong : Theme.palette.border + opacity: control.enabled ? 1.0 : 0.45 + + Behavior on border.color { + ColorAnimation { duration: Theme.motion.fast; easing.type: Theme.motion.easing } + } + + FocusRing { + anchors.fill: parent + anchors.margins: -Theme.space.xxs + radius: parent.radius + Theme.space.xxs + visible: control.visualFocus + } + } + + popup: T.Popup { + y: control.height + Theme.space.xxs + width: control.width + height: Math.min(contentItem.implicitHeight, control.Window.height - topMargin - bottomMargin) + topMargin: Theme.space.sm + bottomMargin: Theme.space.sm + padding: Theme.space.xxs + + // This popup is built inline rather than reused from Popup.qml (the + // ListView content needs control.delegateModel wired in), so it + // repeats that style's open/close fade instead of inheriting it. + enter: Transition { + NumberAnimation { property: "opacity"; from: 0.0; to: 1.0; duration: Theme.motion.fast; easing.type: Theme.motion.easing } + } + exit: Transition { + NumberAnimation { property: "opacity"; from: 1.0; to: 0.0; duration: Theme.motion.fast; easing.type: Theme.motion.easing } + } + + contentItem: ListView { + clip: true + implicitHeight: contentHeight + model: control.delegateModel + currentIndex: control.highlightedIndex + highlightMoveDuration: 0 + + T.ScrollIndicator.vertical: ScrollIndicator { } + } + + background: Rectangle { + radius: Theme.radius.lg + color: Theme.palette.surfaceOverlay + border.width: 1 + border.color: Theme.palette.border + + PopupShadow { radius: parent.radius } + } + } +} diff --git a/launcher/qml/Style/Dialog.qml b/launcher/qml/Style/Dialog.qml new file mode 100644 index 00000000..4f3cb3dd --- /dev/null +++ b/launcher/qml/Style/Dialog.qml @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Templates as T +import MeshMC.Theme + +// No `footer:` is set here: Basic's Dialog wires one up as a DialogButtonBox, +// but DialogButtonBox is not in this style's control list, so giving Dialog +// a footer would mean silently reaching for an unstyled control. A dialog +// that uses control.standardButtons will fall back to the platform default +// until DialogButtonBox is styled -- flagged in this style's report. +T.Dialog { + id: control + + implicitWidth: Math.max(implicitBackgroundWidth + leftInset + rightInset, + implicitContentWidth + leftPadding + rightPadding, + implicitHeaderWidth, + implicitFooterWidth) + implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset, + implicitContentHeight + topPadding + bottomPadding + + (implicitHeaderHeight > 0 ? implicitHeaderHeight + spacing : 0) + + (implicitFooterHeight > 0 ? implicitFooterHeight + spacing : 0)) + + padding: Theme.space.lg + spacing: Theme.space.md + + enter: Transition { + NumberAnimation { property: "opacity"; from: 0.0; to: 1.0; duration: Theme.motion.fast; easing.type: Theme.motion.easing } + NumberAnimation { property: "scale"; from: 0.96; to: 1.0; duration: Theme.motion.fast; easing.type: Theme.motion.easing } + } + exit: Transition { + NumberAnimation { property: "opacity"; from: 1.0; to: 0.0; duration: Theme.motion.fast; easing.type: Theme.motion.easing } + } + + background: Rectangle { + radius: Theme.radius.xl + color: Theme.palette.surfaceOverlay + border.width: 1 + border.color: Theme.palette.border + + PopupShadow { radius: parent.radius } + } + + header: Label { + text: control.title + visible: parent?.parent === T.Overlay.overlay && control.title + elide: Label.ElideRight + font.pixelSize: Theme.type.title.pixelSize + font.weight: Theme.type.title.weight + padding: Theme.space.lg + bottomPadding: 0 + } + + T.Overlay.modal: Rectangle { + color: Theme.palette.scrim + } + + T.Overlay.modeless: Rectangle { + color: "transparent" + } +} diff --git a/launcher/qml/Style/FocusRing.qml b/launcher/qml/Style/FocusRing.qml new file mode 100644 index 00000000..8703fa4c --- /dev/null +++ b/launcher/qml/Style/FocusRing.qml @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import MeshMC.Theme + +// Every interactive control in this style draws its keyboard focus ring the +// same way, so it lives here once instead of being re-typed in twenty control +// files. Bind `visible` to `control.visualFocus` -- never `activeFocus` -- +// because visualFocus is false for a focus gained by mouse/touch press and +// true only for keyboard/programmatic focus. That is what keeps the ring from +// flashing on every click, which is the usual complaint about focus rings. +Rectangle { + id: ring + + // Callers place this as a sibling on top of (or inset from) the control's + // background and set radius/anchors to match its shape. + color: "transparent" + border.width: 2 + border.color: Theme.palette.focusRing + antialiasing: true + + // The ring fades in/out with the rest of the style's state changes rather + // than snapping, so tabbing through a form does not feel jumpy. + Behavior on opacity { + NumberAnimation { duration: Theme.motion.fast; easing.type: Theme.motion.easing } + } + opacity: visible ? 1 : 0 +} diff --git a/launcher/qml/Style/Frame.qml b/launcher/qml/Style/Frame.qml new file mode 100644 index 00000000..5a7794d7 --- /dev/null +++ b/launcher/qml/Style/Frame.qml @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Templates as T +import MeshMC.Theme + +// Frame stays transparent and only outlines its content -- for a filled +// surface, use Pane instead. Keeping the two distinct is what lets "surfaces +// separated by lightness" actually mean something: a Pane inside a Pane +// reads as a step up in lightness, a Frame around either just groups it. +T.Frame { + id: control + + implicitWidth: Math.max(implicitBackgroundWidth + leftInset + rightInset, + implicitContentWidth + leftPadding + rightPadding) + implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset, + implicitContentHeight + topPadding + bottomPadding) + + padding: Theme.space.lg + + background: Rectangle { + radius: Theme.radius.lg + color: "transparent" + border.width: 1 + border.color: Theme.palette.border + } +} diff --git a/launcher/qml/Style/ItemDelegate.qml b/launcher/qml/Style/ItemDelegate.qml new file mode 100644 index 00000000..fab7a4e1 --- /dev/null +++ b/launcher/qml/Style/ItemDelegate.qml @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls.impl +import QtQuick.Templates as T +import MeshMC.Theme + +T.ItemDelegate { + id: control + + implicitWidth: Math.max(implicitBackgroundWidth + leftInset + rightInset, + implicitContentWidth + leftPadding + rightPadding) + implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset, + implicitContentHeight + topPadding + bottomPadding, + implicitIndicatorHeight + topPadding + bottomPadding) + + padding: Theme.space.sm + horizontalPadding: Theme.space.md + spacing: Theme.space.sm + + icon.width: Theme.icon.md + icon.height: Theme.icon.md + icon.color: control.highlighted ? Theme.palette.accentText : Theme.palette.textPrimary + + contentItem: IconLabel { + spacing: control.spacing + mirrored: control.mirrored + display: control.display + alignment: control.display === IconLabel.IconOnly || control.display === IconLabel.TextUnderIcon ? Qt.AlignCenter : Qt.AlignLeft + + icon: control.icon + text: control.text + font.family: Theme.font.family + font.pixelSize: Theme.type.body.pixelSize + font.weight: Theme.type.body.weight + color: control.highlighted ? Theme.palette.accentText : Theme.palette.textPrimary + } + + background: Rectangle { + implicitHeight: Theme.control.height + radius: Theme.radius.sm + color: control.highlighted ? Theme.palette.accentSubtle + : control.down ? Theme.palette.pressedOverlay + : control.hovered ? Theme.palette.hoverOverlay + : "transparent" + + Behavior on color { + ColorAnimation { duration: Theme.motion.fast; easing.type: Theme.motion.easing } + } + + FocusRing { + anchors.fill: parent + anchors.margins: Theme.space.xxs + radius: Theme.radius.sm + visible: control.visualFocus + } + } +} diff --git a/launcher/qml/Style/Label.qml b/launcher/qml/Style/Label.qml new file mode 100644 index 00000000..0303930e --- /dev/null +++ b/launcher/qml/Style/Label.qml @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Templates as T +import MeshMC.Theme + +T.Label { + id: control + + // A sensible default that most call sites never touch; a call site + // that does set font.pixelSize/weight itself overrides this, same as + // any other declaratively-assigned default in QML. + font.family: Theme.font.family + font.pixelSize: Theme.type.body.pixelSize + font.weight: Theme.type.body.weight + + color: control.enabled ? Theme.palette.textPrimary : Theme.palette.textDisabled + linkColor: Theme.palette.accent +} diff --git a/launcher/qml/Style/Menu.qml b/launcher/qml/Style/Menu.qml new file mode 100644 index 00000000..54851983 --- /dev/null +++ b/launcher/qml/Style/Menu.qml @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Templates as T +import MeshMC.Theme + +T.Menu { + id: control + + implicitWidth: Math.max(implicitBackgroundWidth + leftInset + rightInset, + implicitContentWidth + leftPadding + rightPadding) + implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset, + implicitContentHeight + topPadding + bottomPadding) + + margins: 0 + overlap: 1 + padding: Theme.space.xxs + + delegate: MenuItem { } + + contentItem: ListView { + implicitHeight: contentHeight + model: control.contentModel + interactive: Window.window + ? contentHeight + control.topPadding + control.bottomPadding > control.height + : false + clip: true + currentIndex: control.currentIndex + + T.ScrollIndicator.vertical: ScrollIndicator { } + } + + background: Rectangle { + implicitWidth: Theme.control.heightLg * 4 + radius: Theme.radius.lg + color: Theme.palette.surfaceOverlay + border.width: 1 + border.color: Theme.palette.border + + PopupShadow { radius: parent.radius } + } + + enter: Transition { + NumberAnimation { property: "opacity"; from: 0.0; to: 1.0; duration: Theme.motion.fast; easing.type: Theme.motion.easing } + } + exit: Transition { + NumberAnimation { property: "opacity"; from: 1.0; to: 0.0; duration: Theme.motion.fast; easing.type: Theme.motion.easing } + } + + T.Overlay.modal: Rectangle { + color: Theme.palette.scrim + } + + T.Overlay.modeless: Rectangle { + color: "transparent" + } +} diff --git a/launcher/qml/Style/MenuItem.qml b/launcher/qml/Style/MenuItem.qml new file mode 100644 index 00000000..c3f928ab --- /dev/null +++ b/launcher/qml/Style/MenuItem.qml @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls.impl +import QtQuick.Templates as T +import MeshMC.Theme + +T.MenuItem { + id: control + + implicitWidth: Math.max(implicitBackgroundWidth + leftInset + rightInset, + implicitContentWidth + leftPadding + rightPadding) + implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset, + implicitContentHeight + topPadding + bottomPadding, + implicitIndicatorHeight + topPadding + bottomPadding) + + padding: Theme.space.sm + spacing: Theme.space.sm + + icon.width: Theme.icon.md + icon.height: Theme.icon.md + icon.color: control.enabled ? Theme.palette.textPrimary : Theme.palette.textDisabled + + contentItem: IconLabel { + readonly property real arrowPadding: control.subMenu && control.arrow ? control.arrow.width + control.spacing : 0 + readonly property real indicatorPadding: control.checkable && control.indicator ? control.indicator.width + control.spacing : 0 + leftPadding: !control.mirrored ? indicatorPadding : arrowPadding + rightPadding: control.mirrored ? indicatorPadding : arrowPadding + + spacing: control.spacing + mirrored: control.mirrored + display: control.display + alignment: Qt.AlignLeft + + icon: control.icon + text: control.text + font.family: Theme.font.family + font.pixelSize: Theme.type.body.pixelSize + font.weight: Theme.type.body.weight + color: control.enabled ? Theme.palette.textPrimary : Theme.palette.textDisabled + } + + indicator: CheckMark { + x: control.mirrored ? control.width - width - control.rightPadding : control.leftPadding + y: control.topPadding + (control.availableHeight - height) / 2 + width: Theme.icon.sm + height: Theme.icon.sm + color: Theme.palette.accent + visible: control.checkable && control.checked + } + + arrow: Chevron { + x: control.mirrored ? control.leftPadding : control.width - width - control.rightPadding + y: control.topPadding + (control.availableHeight - height) / 2 + width: Theme.icon.sm + height: Theme.icon.sm + color: Theme.palette.textSecondary + direction: 1 + visible: control.subMenu + } + + background: Rectangle { + implicitWidth: Theme.control.heightLg * 4 + implicitHeight: Theme.control.height + x: Theme.space.xxs + y: Theme.space.xxs / 2 + width: control.width - Theme.space.xxs * 2 + height: control.height - Theme.space.xxs + radius: Theme.radius.sm + color: control.down ? Theme.palette.pressedOverlay + : (control.highlighted || control.hovered) ? Theme.palette.hoverOverlay + : "transparent" + } +} diff --git a/launcher/qml/Style/MenuSeparator.qml b/launcher/qml/Style/MenuSeparator.qml new file mode 100644 index 00000000..ea2f6f9d --- /dev/null +++ b/launcher/qml/Style/MenuSeparator.qml @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Templates as T +import MeshMC.Theme + +T.MenuSeparator { + id: control + + implicitWidth: Math.max(implicitBackgroundWidth + leftInset + rightInset, + implicitContentWidth + leftPadding + rightPadding) + implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset, + implicitContentHeight + topPadding + bottomPadding) + + padding: Theme.space.xs + verticalPadding: Theme.space.sm + + contentItem: Rectangle { + implicitWidth: Theme.control.heightLg * 3 + implicitHeight: 1 + color: Theme.palette.divider + } +} diff --git a/launcher/qml/Style/Pane.qml b/launcher/qml/Style/Pane.qml new file mode 100644 index 00000000..bc5eb2ea --- /dev/null +++ b/launcher/qml/Style/Pane.qml @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Templates as T +import MeshMC.Theme + +T.Pane { + id: control + + implicitWidth: Math.max(implicitBackgroundWidth + leftInset + rightInset, + implicitContentWidth + leftPadding + rightPadding) + implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset, + implicitContentHeight + topPadding + bottomPadding) + + padding: Theme.space.lg + + background: Rectangle { + radius: Theme.radius.lg + color: Theme.palette.surface + } +} diff --git a/launcher/qml/Style/Popup.qml b/launcher/qml/Style/Popup.qml new file mode 100644 index 00000000..5a86cc20 --- /dev/null +++ b/launcher/qml/Style/Popup.qml @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Templates as T +import MeshMC.Theme + +T.Popup { + id: control + + implicitWidth: Math.max(implicitBackgroundWidth + leftInset + rightInset, + implicitContentWidth + leftPadding + rightPadding) + implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset, + implicitContentHeight + topPadding + bottomPadding) + + padding: Theme.space.lg + + enter: Transition { + NumberAnimation { property: "opacity"; from: 0.0; to: 1.0; duration: Theme.motion.fast; easing.type: Theme.motion.easing } + } + exit: Transition { + NumberAnimation { property: "opacity"; from: 1.0; to: 0.0; duration: Theme.motion.fast; easing.type: Theme.motion.easing } + } + + background: Rectangle { + radius: Theme.radius.lg + color: Theme.palette.surfaceOverlay + border.width: 1 + border.color: Theme.palette.border + + PopupShadow { radius: parent.radius } + } + + T.Overlay.modal: Rectangle { + color: Theme.palette.scrim + } + + T.Overlay.modeless: Rectangle { + color: "transparent" + } +} diff --git a/launcher/qml/Style/PopupShadow.qml b/launcher/qml/Style/PopupShadow.qml new file mode 100644 index 00000000..f360a71c --- /dev/null +++ b/launcher/qml/Style/PopupShadow.qml @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import MeshMC.Theme + +// A soft drop shadow for a rounded popup/menu/dialog surface, built from two +// oversized, low-opacity rounded rects rather than a blur -- this style's Qt +// floor (6.4) has neither MultiEffect nor ShaderEffect to draw a real one. +// Place as the first child of the surface Rectangle with `radius` matching +// it; declaring it first (and leaving z at the default) is enough to paint +// it behind the surface and its border, since later siblings draw on top. +Item { + id: shadow + + // Radius of the surface this sits behind, kept in sync so the shadow's + // rounding never mismatches the card's own. + property real radius: 0 + + anchors.fill: parent + + Rectangle { + anchors.fill: parent + anchors.margins: -10 + anchors.topMargin: -4 + anchors.bottomMargin: -14 + radius: shadow.radius + 8 + color: Theme.palette.scrim + opacity: 0.14 + } + + Rectangle { + anchors.fill: parent + anchors.margins: -4 + anchors.topMargin: -1 + anchors.bottomMargin: -6 + radius: shadow.radius + 3 + color: Theme.palette.scrim + opacity: 0.20 + } +} diff --git a/launcher/qml/Style/ProgressBar.qml b/launcher/qml/Style/ProgressBar.qml new file mode 100644 index 00000000..bbf01376 --- /dev/null +++ b/launcher/qml/Style/ProgressBar.qml @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Templates as T +import MeshMC.Theme + +// Basic's indeterminate mode comes from QtQuick.Controls.Basic.impl +// (ProgressBarImpl), a helper private to that style's plugin. This style +// does not link the Basic plugin, so indeterminate progress is a plain +// looping NumberAnimation on a short pill instead -- the classic "chasing" +// indicator, needing nothing beyond QtQuick. +T.ProgressBar { + id: control + + implicitWidth: Math.max(implicitBackgroundWidth + leftInset + rightInset, + implicitContentWidth + leftPadding + rightPadding) + implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset, + implicitContentHeight + topPadding + bottomPadding) + + contentItem: Item { + implicitWidth: Theme.control.heightLg * 4 + implicitHeight: Theme.space.xs + + Rectangle { + id: determinateFill + visible: !control.indeterminate + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + width: parent.width * control.position + radius: Theme.radius.pill + color: control.enabled ? Theme.palette.accent : Theme.palette.textDisabled + + Behavior on width { + // A progress value is data updating, not a hover/press + // response, so it gets the calmer "normal" duration rather + // than "fast". + NumberAnimation { duration: Theme.motion.normal; easing.type: Theme.motion.easing } + } + } + + Item { + id: indeterminateTrack + visible: control.indeterminate + anchors.fill: parent + clip: true + + Rectangle { + id: chaser + width: parent.width * 0.3 + height: parent.height + radius: Theme.radius.pill + color: Theme.palette.accent + + SequentialAnimation on x { + // This is a perpetual loading affordance, not a state + // transition, so it is exempt from the "nothing animates + // longer than Theme.motion.normal" rule -- that rule + // governs hover/press/focus/toggle feedback, which by + // definition settles; a "still working" indicator cannot. + loops: Animation.Infinite + running: control.indeterminate && control.visible + NumberAnimation { from: -chaser.width; to: indeterminateTrack.width; duration: 1100; easing.type: Easing.InOutQuad } + PauseAnimation { duration: 150 } + } + } + } + } + + background: Rectangle { + implicitWidth: Theme.control.heightLg * 4 + implicitHeight: Theme.space.xs + radius: Theme.radius.pill + color: Theme.palette.surfaceRaised + } +} diff --git a/launcher/qml/Style/RadioButton.qml b/launcher/qml/Style/RadioButton.qml new file mode 100644 index 00000000..8d822b5c --- /dev/null +++ b/launcher/qml/Style/RadioButton.qml @@ -0,0 +1,77 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Templates as T +import MeshMC.Theme + +T.RadioButton { + id: control + + implicitWidth: Math.max(implicitBackgroundWidth + leftInset + rightInset, + implicitContentWidth + leftPadding + rightPadding) + implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset, + implicitContentHeight + topPadding + bottomPadding, + implicitIndicatorHeight + topPadding + bottomPadding) + + padding: Theme.space.xs + spacing: Theme.space.sm + + indicator: Rectangle { + id: ring + implicitWidth: Theme.icon.lg + implicitHeight: Theme.icon.lg + + x: control.text ? (control.mirrored ? control.width - width - control.rightPadding : control.leftPadding) : control.leftPadding + (control.availableWidth - width) / 2 + y: control.topPadding + (control.availableHeight - height) / 2 + + radius: width / 2 + color: control.checked + ? (control.down ? Theme.palette.accentPressed : control.hovered ? Theme.palette.accentHover : Theme.palette.accent) + : Theme.palette.surfaceRaised + border.width: control.checked ? 0 : 1 + border.color: Theme.palette.border + opacity: control.enabled ? 1.0 : 0.45 + + Behavior on color { + ColorAnimation { duration: Theme.motion.fast; easing.type: Theme.motion.easing } + } + + Rectangle { + anchors.fill: parent + radius: parent.radius + visible: !control.checked + color: control.down ? Theme.palette.pressedOverlay : control.hovered ? Theme.palette.hoverOverlay : "transparent" + } + + Rectangle { + anchors.centerIn: parent + width: parent.width - Theme.space.sm * 2 + height: width + radius: width / 2 + color: Theme.palette.textOnAccent + visible: control.checked + } + + FocusRing { + anchors.fill: parent + anchors.margins: -Theme.space.xxs + radius: parent.radius + Theme.space.xxs + visible: control.visualFocus + } + } + + contentItem: Text { + leftPadding: control.indicator && !control.mirrored ? control.indicator.width + control.spacing : 0 + rightPadding: control.indicator && control.mirrored ? control.indicator.width + control.spacing : 0 + + text: control.text + font.family: Theme.font.family + font.pixelSize: Theme.type.body.pixelSize + font.weight: Theme.type.body.weight + color: control.enabled ? Theme.palette.textPrimary : Theme.palette.textDisabled + elide: Text.ElideRight + verticalAlignment: Text.AlignVCenter + } +} diff --git a/launcher/qml/Style/ScrollBar.qml b/launcher/qml/Style/ScrollBar.qml new file mode 100644 index 00000000..408d76bc --- /dev/null +++ b/launcher/qml/Style/ScrollBar.qml @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Templates as T +import MeshMC.Theme + +T.ScrollBar { + id: control + + implicitWidth: Math.max(implicitBackgroundWidth + leftInset + rightInset, + implicitContentWidth + leftPadding + rightPadding) + implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset, + implicitContentHeight + topPadding + bottomPadding) + + padding: Theme.space.xxs + visible: control.policy !== T.ScrollBar.AlwaysOff + minimumSize: orientation === Qt.Horizontal ? height / width : width / height + + contentItem: Rectangle { + implicitWidth: control.interactive ? Theme.space.sm : Theme.space.xxs + implicitHeight: control.interactive ? Theme.space.sm : Theme.space.xxs + + radius: width / 2 + color: control.pressed ? Theme.palette.textTertiary : Theme.palette.borderStrong + opacity: 0.0 + + states: State { + name: "active" + when: control.policy === T.ScrollBar.AlwaysOn || (control.active && control.size < 1.0) + PropertyChanges { control.contentItem.opacity: 0.75 } + } + + transitions: Transition { + from: "active" + SequentialAnimation { + PauseAnimation { duration: Theme.motion.slow } + NumberAnimation { target: control.contentItem; duration: Theme.motion.normal; property: "opacity"; to: 0.0 } + } + } + } +} diff --git a/launcher/qml/Style/ScrollIndicator.qml b/launcher/qml/Style/ScrollIndicator.qml new file mode 100644 index 00000000..612ed0d2 --- /dev/null +++ b/launcher/qml/Style/ScrollIndicator.qml @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Templates as T +import MeshMC.Theme + +T.ScrollIndicator { + id: control + + implicitWidth: Math.max(implicitBackgroundWidth + leftInset + rightInset, + implicitContentWidth + leftPadding + rightPadding) + implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset, + implicitContentHeight + topPadding + bottomPadding) + + padding: Theme.space.xxs + + contentItem: Rectangle { + implicitWidth: Theme.space.xxs + implicitHeight: Theme.space.xxs + + radius: width / 2 + color: Theme.palette.borderStrong + visible: control.size < 1.0 + opacity: 0.0 + + states: State { + name: "active" + when: control.active + PropertyChanges { control.contentItem.opacity: 0.75 } + } + + transitions: [ + Transition { + from: "active" + SequentialAnimation { + PauseAnimation { duration: Theme.motion.slow } + NumberAnimation { target: control.contentItem; duration: Theme.motion.normal; property: "opacity"; to: 0.0 } + } + } + ] + } +} diff --git a/launcher/qml/Style/Slider.qml b/launcher/qml/Style/Slider.qml new file mode 100644 index 00000000..6f55abff --- /dev/null +++ b/launcher/qml/Style/Slider.qml @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Templates as T +import MeshMC.Theme + +T.Slider { + id: control + + implicitWidth: Math.max(implicitBackgroundWidth + leftInset + rightInset, + implicitHandleWidth + leftPadding + rightPadding) + implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset, + implicitHandleHeight + topPadding + bottomPadding) + + padding: Theme.space.sm + + handle: Rectangle { + x: control.leftPadding + (control.horizontal ? control.visualPosition * (control.availableWidth - width) : (control.availableWidth - width) / 2) + y: control.topPadding + (control.horizontal ? (control.availableHeight - height) / 2 : control.visualPosition * (control.availableHeight - height)) + implicitWidth: Theme.icon.md + implicitHeight: Theme.icon.md + radius: width / 2 + // A light knob in both themes: on a dark card a surface-coloured one + // disappears into the groove. + color: Theme.dark ? Theme.palette.textPrimary : Theme.palette.surface + border.width: Theme.dark ? 0 : 1 + border.color: Theme.palette.borderStrong + opacity: control.enabled ? 1.0 : Theme.opacity.disabled + scale: control.pressed ? 1.1 : 1.0 + + Behavior on scale { + NumberAnimation { duration: Theme.motion.fast; easing.type: Theme.motion.easing } + } + + FocusRing { + anchors.fill: parent + anchors.margins: -Theme.space.xxs + radius: parent.radius + Theme.space.xxs + visible: control.visualFocus + } + } + + background: Rectangle { + x: control.leftPadding + (control.horizontal ? 0 : (control.availableWidth - width) / 2) + y: control.topPadding + (control.horizontal ? (control.availableHeight - height) / 2 : 0) + implicitWidth: control.horizontal ? Theme.control.heightLg * 4 : Theme.space.xs + implicitHeight: control.horizontal ? Theme.space.xs : Theme.control.heightLg * 4 + width: control.horizontal ? control.availableWidth : implicitWidth + height: control.horizontal ? implicitHeight : control.availableHeight + radius: Theme.radius.pill + color: Theme.palette.surfaceOverlay + scale: control.horizontal && control.mirrored ? -1 : 1 + + Rectangle { + y: control.horizontal ? 0 : control.visualPosition * parent.height + width: control.horizontal ? control.position * parent.width : parent.width + height: control.horizontal ? parent.height : control.position * parent.height + radius: Theme.radius.pill + color: control.enabled ? Theme.palette.accent : Theme.palette.textDisabled + } + } +} diff --git a/launcher/qml/Style/SpinBox.qml b/launcher/qml/Style/SpinBox.qml new file mode 100644 index 00000000..f49888f8 --- /dev/null +++ b/launcher/qml/Style/SpinBox.qml @@ -0,0 +1,121 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Templates as T +import MeshMC.Theme + +// One pill-shaped field with plus/minus zones at either end, rather than +// Basic's two separately-coloured indicator boxes bolted onto a third box -- +// that reads as three widgets; this reads as one control with two active +// edges, closer to the calmer, single-surface language the rest of the style +// uses for fields. +T.SpinBox { + id: control + + implicitWidth: Math.max(implicitBackgroundWidth + leftInset + rightInset, + contentItem.implicitWidth + leftPadding + rightPadding) + implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset, + implicitContentHeight + topPadding + bottomPadding, + up.implicitIndicatorHeight, down.implicitIndicatorHeight) + + leftPadding: control.mirrored ? (up.indicator ? up.indicator.width : 0) : (down.indicator ? down.indicator.width : 0) + rightPadding: control.mirrored ? (down.indicator ? down.indicator.width : 0) : (up.indicator ? up.indicator.width : 0) + + validator: IntValidator { + locale: control.locale.name + bottom: Math.min(control.from, control.to) + top: Math.max(control.from, control.to) + } + + contentItem: TextInput { + z: 2 + text: control.displayText + clip: width < implicitWidth + padding: Theme.space.sm + + font.family: Theme.font.family + font.pixelSize: Theme.type.body.pixelSize + font.weight: Theme.type.body.weight + color: control.enabled ? Theme.palette.textPrimary : Theme.palette.textDisabled + selectionColor: Theme.palette.selection + selectedTextColor: Theme.palette.selectionText + horizontalAlignment: Qt.AlignHCenter + verticalAlignment: Qt.AlignVCenter + + readOnly: !control.editable + validator: control.validator + inputMethodHints: control.inputMethodHints + } + + up.indicator: Rectangle { + x: control.mirrored ? 0 : control.width - width + height: control.height + implicitWidth: Theme.control.height + implicitHeight: Theme.control.height + color: control.up.pressed ? Theme.palette.pressedOverlay : control.up.hovered ? Theme.palette.hoverOverlay : "transparent" + + Rectangle { + x: 0 + width: 1 + height: parent.height + color: Theme.palette.divider + } + + Rectangle { + anchors.centerIn: parent + width: Theme.icon.sm + height: 2 + radius: 1 + color: control.enabled ? Theme.palette.textSecondary : Theme.palette.textDisabled + } + Rectangle { + anchors.centerIn: parent + width: 2 + height: Theme.icon.sm + radius: 1 + color: control.enabled ? Theme.palette.textSecondary : Theme.palette.textDisabled + } + } + + down.indicator: Rectangle { + x: control.mirrored ? control.width - width : 0 + height: control.height + implicitWidth: Theme.control.height + implicitHeight: Theme.control.height + color: control.down.pressed ? Theme.palette.pressedOverlay : control.down.hovered ? Theme.palette.hoverOverlay : "transparent" + + Rectangle { + x: parent.width - 1 + width: 1 + height: parent.height + color: Theme.palette.divider + } + + Rectangle { + anchors.centerIn: parent + width: Theme.icon.sm + height: 2 + radius: 1 + color: control.enabled ? Theme.palette.textSecondary : Theme.palette.textDisabled + } + } + + background: Rectangle { + implicitWidth: Theme.control.heightLg * 3 + radius: Theme.radius.md + color: Theme.palette.surfaceSunken + border.width: 1 + border.color: control.activeFocus ? Theme.palette.borderStrong : Theme.palette.border + opacity: control.enabled ? 1.0 : 0.45 + clip: true + + FocusRing { + anchors.fill: parent + anchors.margins: -Theme.space.xxs + radius: parent.radius + Theme.space.xxs + visible: control.visualFocus + } + } +} diff --git a/launcher/qml/Style/Switch.qml b/launcher/qml/Style/Switch.qml new file mode 100644 index 00000000..831e4976 --- /dev/null +++ b/launcher/qml/Style/Switch.qml @@ -0,0 +1,82 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Templates as T +import MeshMC.Theme + +T.Switch { + id: control + + implicitWidth: Math.max(implicitBackgroundWidth + leftInset + rightInset, + implicitContentWidth + leftPadding + rightPadding) + implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset, + implicitContentHeight + topPadding + bottomPadding, + implicitIndicatorHeight + topPadding + bottomPadding) + + padding: Theme.space.xs + spacing: Theme.space.sm + + indicator: Rectangle { + id: track + implicitWidth: Theme.icon.lg * 2 + implicitHeight: Theme.icon.lg + + x: control.text ? (control.mirrored ? control.width - width - control.rightPadding : control.leftPadding) : control.leftPadding + (control.availableWidth - width) / 2 + y: control.topPadding + (control.availableHeight - height) / 2 + + radius: Theme.radius.pill + color: control.checked + ? (control.down ? Theme.palette.accentPressed : control.hovered ? Theme.palette.accentHover : Theme.palette.accent) + : Theme.palette.surfaceOverlay + border.width: control.checked ? 0 : 1 + // Off has to read as a control, not as an empty slot. + border.color: control.hovered ? Theme.palette.borderStrong : Theme.palette.border + opacity: control.enabled ? 1.0 : Theme.opacity.disabled + + Behavior on color { + ColorAnimation { duration: Theme.motion.fast; easing.type: Theme.motion.easing } + } + + Rectangle { + id: thumb + width: track.height - Theme.space.xxs * 2 + height: width + radius: width / 2 + y: (track.height - height) / 2 + x: Math.max(Theme.space.xxs, Math.min(track.width - width - Theme.space.xxs, + control.visualPosition * (track.width - width))) + color: control.checked ? Theme.palette.textOnAccent : Theme.palette.textSecondary + + // No Behavior while the thumb is actively being dragged, so it + // tracks the pointer 1:1; the animation is only for the + // click-to-toggle case, where a snap would look unfinished next + // to every other state change in this style easing in. + Behavior on x { + enabled: !control.down + NumberAnimation { duration: Theme.motion.fast; easing.type: Theme.motion.easing } + } + } + + FocusRing { + anchors.fill: parent + anchors.margins: -Theme.space.xxs + radius: parent.radius + visible: control.visualFocus + } + } + + contentItem: Text { + leftPadding: control.indicator && !control.mirrored ? control.indicator.width + control.spacing : 0 + rightPadding: control.indicator && control.mirrored ? control.indicator.width + control.spacing : 0 + + text: control.text + font.family: Theme.font.family + font.pixelSize: Theme.type.body.pixelSize + font.weight: Theme.type.body.weight + color: control.enabled ? Theme.palette.textPrimary : Theme.palette.textDisabled + elide: Text.ElideRight + verticalAlignment: Text.AlignVCenter + } +} diff --git a/launcher/qml/Style/TabBar.qml b/launcher/qml/Style/TabBar.qml new file mode 100644 index 00000000..80af5cee --- /dev/null +++ b/launcher/qml/Style/TabBar.qml @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Templates as T +import MeshMC.Theme + +T.TabBar { + id: control + + implicitWidth: Math.max(implicitBackgroundWidth + leftInset + rightInset, + implicitContentWidth + leftPadding + rightPadding) + implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset, + implicitContentHeight + topPadding + bottomPadding) + + spacing: Theme.space.sm + + contentItem: ListView { + model: control.contentModel + currentIndex: control.currentIndex + + spacing: control.spacing + orientation: ListView.Horizontal + boundsBehavior: Flickable.StopAtBounds + flickableDirection: Flickable.AutoFlickIfNeeded + snapMode: ListView.SnapToItem + + highlightMoveDuration: Theme.motion.normal + highlightRangeMode: ListView.ApplyRange + preferredHighlightBegin: Theme.control.heightLg + preferredHighlightEnd: width - Theme.control.heightLg + } + + background: Rectangle { + color: "transparent" + + // One hairline for the whole bar rather than each TabButton drawing + // its own bottom border -- otherwise adjoining tabs would double up + // the line at the seam and it would read thicker than every other + // divider in the UI. + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 1 + color: Theme.palette.divider + } + } +} diff --git a/launcher/qml/Style/TabButton.qml b/launcher/qml/Style/TabButton.qml new file mode 100644 index 00000000..077e9aee --- /dev/null +++ b/launcher/qml/Style/TabButton.qml @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls.impl +import QtQuick.Templates as T +import MeshMC.Theme + +// An underline tab rather than Basic's filled-box tab: it reads lighter and +// leaves the accent doing one job (marking the active tab) instead of also +// colouring a whole box, which matches the brief's "accent used sparingly". +T.TabButton { + id: control + + implicitWidth: Math.max(implicitBackgroundWidth + leftInset + rightInset, + implicitContentWidth + leftPadding + rightPadding) + implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset, + implicitContentHeight + topPadding + bottomPadding) + + padding: Theme.space.sm + horizontalPadding: Theme.space.md + spacing: Theme.space.xs + + icon.width: Theme.icon.sm + icon.height: Theme.icon.sm + icon.color: control.checked ? Theme.palette.accentText : Theme.palette.textSecondary + + contentItem: IconLabel { + spacing: control.spacing + mirrored: control.mirrored + display: control.display + + icon: control.icon + text: control.text + font.family: Theme.font.family + font.pixelSize: Theme.type.label.pixelSize + font.weight: control.checked ? Theme.type.bodyStrong.weight : Theme.type.label.weight + color: control.checked ? Theme.palette.accentText : Theme.palette.textSecondary + } + + background: Rectangle { + implicitHeight: Theme.control.height + color: control.down ? Theme.palette.pressedOverlay : control.hovered ? Theme.palette.hoverOverlay : "transparent" + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 2 + radius: 1 + color: Theme.palette.accent + opacity: control.checked ? 1.0 : 0.0 + + Behavior on opacity { + NumberAnimation { duration: Theme.motion.fast; easing.type: Theme.motion.easing } + } + } + + FocusRing { + anchors.fill: parent + anchors.margins: Theme.space.xxs + radius: Theme.radius.sm + visible: control.visualFocus + } + } +} diff --git a/launcher/qml/Style/TextArea.qml b/launcher/qml/Style/TextArea.qml new file mode 100644 index 00000000..2468de0d --- /dev/null +++ b/launcher/qml/Style/TextArea.qml @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls.impl +import QtQuick.Templates as T +import MeshMC.Theme + +// Unlike Basic's TextArea, this one draws its own background rather than +// leaving it to whatever Frame/ScrollView happens to wrap it -- most call +// sites here use a bare TextArea, and an unstyled one would show raw text +// floating on the surface behind it, breaking the "sunken field" language +// TextField establishes. +T.TextArea { + id: control + + implicitWidth: Math.max(contentWidth + leftPadding + rightPadding, + implicitBackgroundWidth + leftInset + rightInset, + placeholder.implicitWidth + leftPadding + rightPadding) + implicitHeight: Math.max(contentHeight + topPadding + bottomPadding, + implicitBackgroundHeight + topInset + bottomInset, + placeholder.implicitHeight + topPadding + bottomPadding) + + padding: Theme.space.sm + + color: control.enabled ? Theme.palette.textPrimary : Theme.palette.textDisabled + font.family: Theme.font.family + font.pixelSize: Theme.type.body.pixelSize + font.weight: Theme.type.body.weight + placeholderTextColor: Theme.palette.textTertiary + selectionColor: Theme.palette.selection + selectedTextColor: Theme.palette.selectionText + + PlaceholderText { + id: placeholder + x: control.leftPadding + y: control.topPadding + width: control.width - (control.leftPadding + control.rightPadding) + height: control.height - (control.topPadding + control.bottomPadding) + + text: control.placeholderText + font: control.font + color: control.placeholderTextColor + verticalAlignment: control.verticalAlignment + visible: !control.length && !control.preeditText && (!control.activeFocus || control.horizontalAlignment !== Qt.AlignHCenter) + elide: Text.ElideRight + renderType: control.renderType + } + + background: Rectangle { + implicitWidth: Theme.control.heightLg * 4 + implicitHeight: Theme.control.heightLg * 2 + radius: Theme.radius.md + color: Theme.palette.surfaceSunken + border.width: 1 + border.color: control.activeFocus ? Theme.palette.borderStrong : Theme.palette.border + opacity: control.enabled ? 1.0 : 0.45 + + Behavior on border.color { + ColorAnimation { duration: Theme.motion.fast; easing.type: Theme.motion.easing } + } + + FocusRing { + anchors.fill: parent + anchors.margins: -Theme.space.xxs + radius: parent.radius + Theme.space.xxs + // T.TextArea has no visualFocus for the same reason as + // TextField.qml: it isn't Control-derived. Same "keyboard, not + // mouse" test as Control computes internally, done by hand. + visible: control.activeFocus && control.focusReason !== Qt.MouseFocusReason + } + } +} diff --git a/launcher/qml/Style/TextField.qml b/launcher/qml/Style/TextField.qml new file mode 100644 index 00000000..af08cdc1 --- /dev/null +++ b/launcher/qml/Style/TextField.qml @@ -0,0 +1,79 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls.impl +import QtQuick.Templates as T +import MeshMC.Theme + +// Inputs sit in a sunken surface rather than behind a heavy border, per the +// brief's "surfaces separated by lightness rather than by lines" -- the +// field reads as a recess in the panel, not a boxed-off widget. +T.TextField { + id: control + + implicitWidth: implicitBackgroundWidth + leftInset + rightInset + || Math.max(contentWidth, placeholder.implicitWidth) + leftPadding + rightPadding + implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset, + contentHeight + topPadding + bottomPadding, + placeholder.implicitHeight + topPadding + bottomPadding) + + leftPadding: Theme.space.md + rightPadding: Theme.space.md + topPadding: Theme.space.xs + bottomPadding: Theme.space.xs + + color: control.enabled ? Theme.palette.textPrimary : Theme.palette.textDisabled + font.family: Theme.font.family + font.pixelSize: Theme.type.body.pixelSize + font.weight: Theme.type.body.weight + selectionColor: Theme.palette.selection + selectedTextColor: Theme.palette.selectionText + placeholderTextColor: Theme.palette.textTertiary + verticalAlignment: TextInput.AlignVCenter + + PlaceholderText { + id: placeholder + x: control.leftPadding + y: control.topPadding + width: control.width - (control.leftPadding + control.rightPadding) + height: control.height - (control.topPadding + control.bottomPadding) + + text: control.placeholderText + font: control.font + color: control.placeholderTextColor + verticalAlignment: control.verticalAlignment + visible: !control.length && !control.preeditText && (!control.activeFocus || control.horizontalAlignment !== Qt.AlignHCenter) + elide: Text.ElideRight + renderType: control.renderType + } + + background: Rectangle { + implicitWidth: Theme.control.heightLg * 4 + implicitHeight: Theme.control.height + radius: Theme.radius.md + color: Theme.palette.surfaceRaised + border.width: 1 + // An accent border on activeFocus tells a mouse user they landed in + // the field; the outset ring below is the separate, keyboard-only + // affordance the brief asks for. + border.color: control.activeFocus ? Theme.palette.accent + : control.hovered ? Theme.palette.borderStrong : Theme.palette.border + opacity: control.enabled ? 1.0 : Theme.opacity.disabled + + Behavior on border.color { + ColorAnimation { duration: Theme.motion.fast; easing.type: Theme.motion.easing } + } + + FocusRing { + anchors.fill: parent + anchors.margins: -Theme.space.xxs + radius: parent.radius + Theme.space.xxs + // T.TextField has no visualFocus (it isn't Control-derived, so it + // never gained the property) -- this is the same "keyboard, not + // mouse" test Control computes internally. + visible: control.activeFocus && control.focusReason !== Qt.MouseFocusReason + } + } +} diff --git a/launcher/qml/Style/ToolButton.qml b/launcher/qml/Style/ToolButton.qml new file mode 100644 index 00000000..eee85056 --- /dev/null +++ b/launcher/qml/Style/ToolButton.qml @@ -0,0 +1,69 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Controls.impl +import QtQuick.Templates as T +import MeshMC.Theme + +// A toolbar/icon button: quiet by default (transparent, only the hover/press +// overlay shows), with a soft accent-tinted fill when checked so a toggled +// tool (e.g. a pinned view) reads as "on" without borrowing the primary +// Button's full accent fill, which is reserved for committing actions. +T.ToolButton { + id: control + + implicitWidth: Math.max(implicitBackgroundWidth + leftInset + rightInset, + implicitContentWidth + leftPadding + rightPadding) + implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset, + implicitContentHeight + topPadding + bottomPadding) + + padding: Theme.space.sm + spacing: Theme.space.xs + + icon.width: Theme.icon.md + icon.height: Theme.icon.md + icon.color: contentColor + + readonly property color contentColor: !control.enabled + ? Theme.palette.textDisabled + : (control.checked || control.highlighted) ? Theme.palette.accentText : Theme.palette.textPrimary + + contentItem: IconLabel { + spacing: control.spacing + mirrored: control.mirrored + display: control.display + + icon: control.icon + text: control.text + font.family: Theme.font.family + font.pixelSize: Theme.type.body.pixelSize + font.weight: Theme.type.body.weight + color: control.contentColor + } + + background: Rectangle { + implicitWidth: Theme.control.height + implicitHeight: Theme.control.height + radius: Theme.radius.md + color: (control.checked || control.highlighted) ? Theme.palette.accentSubtle : "transparent" + opacity: control.enabled ? 1.0 : 0.45 + + Rectangle { + anchors.fill: parent + radius: parent.radius + color: control.down ? Theme.palette.pressedOverlay : control.hovered ? Theme.palette.hoverOverlay : "transparent" + Behavior on color { + ColorAnimation { duration: Theme.motion.fast; easing.type: Theme.motion.easing } + } + } + + FocusRing { + anchors.fill: parent + anchors.margins: -Theme.space.xxs + radius: parent.radius + Theme.space.xxs + visible: control.visualFocus + } + } +} diff --git a/launcher/qml/Style/ToolTip.qml b/launcher/qml/Style/ToolTip.qml new file mode 100644 index 00000000..122684bf --- /dev/null +++ b/launcher/qml/Style/ToolTip.qml @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +import QtQuick +import QtQuick.Templates as T +import MeshMC.Theme + +T.ToolTip { + id: control + + x: parent ? (parent.width - implicitWidth) / 2 : 0 + y: -implicitHeight - Theme.space.xs + + implicitWidth: Math.max(implicitBackgroundWidth + leftInset + rightInset, + implicitContentWidth + leftPadding + rightPadding) + implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset, + implicitContentHeight + topPadding + bottomPadding) + + margins: Theme.space.xs + padding: Theme.space.sm + + closePolicy: T.Popup.CloseOnEscape | T.Popup.CloseOnPressOutsideParent | T.Popup.CloseOnReleaseOutsideParent + + enter: Transition { + NumberAnimation { property: "opacity"; from: 0.0; to: 1.0; duration: Theme.motion.fast; easing.type: Theme.motion.easing } + } + exit: Transition { + NumberAnimation { property: "opacity"; from: 1.0; to: 0.0; duration: Theme.motion.fast; easing.type: Theme.motion.easing } + } + + contentItem: Text { + text: control.text + font.family: Theme.font.family + font.pixelSize: Theme.type.caption.pixelSize + font.weight: Theme.type.caption.weight + wrapMode: Text.Wrap + color: Theme.palette.tooltipText + } + + background: Rectangle { + radius: Theme.radius.sm + color: Theme.palette.tooltipBackground + } +} diff --git a/launcher/qml/Theme/CMakeLists.txt b/launcher/qml/Theme/CMakeLists.txt new file mode 100644 index 00000000..f2c91147 --- /dev/null +++ b/launcher/qml/Theme/CMakeLists.txt @@ -0,0 +1,76 @@ +# SPDX-FileCopyrightText: 2026 Project Tick +# SPDX-FileContributor: Project Tick +# SPDX-License-Identifier: Apache-2.0 + +######## MeshMC.Theme: the design-token QML module ######## + +# Its own directory, one level below launcher/qml/, for the same reason +# MeshMC itself sits in launcher/qml/ rather than launcher/: qt_add_qml_module +# places files at ${RESOURCE_PREFIX}/${URI as path}/..., so declaring the +# module from the directory that mirrors "MeshMC/Theme" keeps the alias a bare +# Theme.qml instead of the source tree leaking into the import path. Not added +# to the parent CMakeLists here -- the integrator wires in add_subdirectory(). + +# Has to run before qt_add_qml_module: QT_QML_SINGLETON_TYPE is read while the +# module is generated, and setting it after would leave Theme.qml registered +# as an ordinary (re-instantiable) type, silently handing every "import +# MeshMC.Theme" its own private Theme instead of the one every component is +# meant to share. +set_source_files_properties(Theme.qml PROPERTIES QT_QML_SINGLETON_TYPE TRUE) + +qt_add_library(MeshMC_qml_theme STATIC) + +qt_add_qml_module(MeshMC_qml_theme + URI MeshMC.Theme + VERSION 1.0 + RESOURCE_PREFIX "/qt/qml" + QML_FILES + Theme.qml + SOURCES + ThemeService.h + ThemeService.cpp + # Inter 4.1 static TTFs (+ their OFL.txt), bundled here rather than as a + # loose file on disk: Theme.qml's own FontLoader instances load them by + # qrc path so Theme.font.family is real on every machine, not only one + # that happens to already have Inter installed system-wide. Living next + # to Theme.qml keeps the module and the asset it needs to function in one + # place -- see the comment on Theme.qml's font.family. + RESOURCES + fonts/inter/Inter-Regular.ttf + fonts/inter/Inter-Medium.ttf + fonts/inter/Inter-SemiBold.ttf + fonts/inter/Inter-Bold.ttf + fonts/inter/OFL.txt +) + +# ThemeService only reads ThemePalette (theme/ThemePalette.h, part of the +# core); it must not see QtWidgets or launcher/ui/, so this links MeshMC_core +# alone, never MeshMC_logic. +target_link_libraries(MeshMC_qml_theme PUBLIC + MeshMC_core + Qt${QT_VERSION_MAJOR}::Quick + Qt${QT_VERSION_MAJOR}::Gui +) + +######## Unit test ######## + +# Same shape as qml/QmlModule_test.cpp: proves the module resolves and its +# tokens read back the values declared above -- a shifted resource prefix, a +# qmlcachegen failure or the singleton registering as a plain type would all +# otherwise pass a green build silently. +add_unit_test(Theme + SOURCES Theme_test.cpp + # MeshMC_qml_theme explicitly: the test counts ThemeService::changed from + # C++, and qt_import_qml_plugins only brings in what QML itself reaches. + LIBS MeshMC_qml_theme MeshMC_qml_themeplugin MeshMC_core Qt${QT_VERSION_MAJOR}::Quick Qt${QT_VERSION_MAJOR}::Qml Qt${QT_VERSION_MAJOR}::Gui + ) + +# qt_add_qml_module builds the module as a static library plus a separate QML +# plugin, and the engine refuses the module without it; add_unit_test uses a +# plain add_executable, which -- unlike qt_add_executable -- never imports it +# on its own. Same reasoning as QmlModule_test in launcher/CMakeLists.txt. +# +# The plugin target is also listed in LIBS above, explicitly: this test's QML +# is inline (QQmlComponent::setData), so qmlimportscanner never sees the +# `import MeshMC.Theme` and cannot work out on its own that it is needed. +qt_import_qml_plugins(Theme_test) diff --git a/launcher/qml/Theme/Theme.qml b/launcher/qml/Theme/Theme.qml new file mode 100644 index 00000000..76e39e43 --- /dev/null +++ b/launcher/qml/Theme/Theme.qml @@ -0,0 +1,234 @@ +// SPDX-FileCopyrightText: 2026 Project Tick +// SPDX-FileContributor: Project Tick +// SPDX-License-Identifier: Apache-2.0 + +pragma Singleton +import QtQuick + +/* + * The single source of design tokens for the QML user interface: every size, + * colour and timing a component asks for comes from here, never a literal + * baked into that component. + * + * palette/dark/mode forward straight to ThemeService -- this module's other + * QML singleton, registered alongside this file in C++ -- so the palette-swap + * logic lives in exactly one place and this file only has to relay it. + * Everything below that (space, radius, control, icon, motion, font, type) is + * a plain constant this file alone owns. + */ +QtObject { + id: root + + // ThemeService is this module's own C++ singleton; referring to it by + // name is enough; the module makes it visible without an import, same as + // any other type declared in it. + readonly property var palette: ThemeService.palette + readonly property bool dark: ThemeService.dark + property string mode: ThemeService.mode + onModeChanged: ThemeService.mode = mode + // "amethyst", "ember", "diamond" or "grass". + property string scheme: ThemeService.scheme + onSchemeChanged: ThemeService.scheme = scheme + function previewPalette(name) { return ThemeService.previewPalette(name) } + + readonly property QtObject space: QtObject { + readonly property int xxs: 2 + readonly property int xs: 4 + readonly property int sm: 8 + readonly property int md: 12 + readonly property int lg: 16 + readonly property int xl: 24 + readonly property int xxl: 32 + } + + // Contract: `pill` is reserved for genuine toggle/chip affordances + // (a switch track, a filter chip) -- never for the Play verb or an + // instance's own identity (cover corners, the icon picker, PlayButton's + // wide hero shape), which are capped at `md`/`lg` instead. A blocky game + // launcher asking to look professional doesn't have to be as rounded as + // a fintech app; see design-plan.md §1/§2.7. + readonly property QtObject radius: QtObject { + readonly property int xs: 2 + readonly property int sm: 4 + readonly property int md: 8 + readonly property int lg: 12 + readonly property int xl: 16 + readonly property int pill: 999 + } + + readonly property QtObject control: QtObject { + readonly property int heightSm: 28 + readonly property int height: 36 + readonly property int heightLg: 44 + } + + readonly property QtObject icon: QtObject { + readonly property int sm: 16 + readonly property int md: 20 + readonly property int lg: 24 + } + + readonly property QtObject opacity: QtObject { + // A disabled control keeps its shape and fades, rather than turning + // into a different-looking control. + readonly property real disabled: 0.45 + } + + // Text and shading laid over a screenshot or other artwork. The art is + // what it is in either theme, so these do not follow the palette: light + // text over a dark fade reads on any picture, in dark and light mode. + readonly property QtObject media: QtObject { + readonly property color text: "#FFFFFF" + readonly property color textSecondary: Qt.rgba(1, 1, 1, 0.80) + readonly property color scrim: Qt.rgba(0.02, 0.03, 0.05, 0.86) + readonly property color chip: Qt.rgba(0, 0, 0, 0.38) + readonly property color chipBorder: Qt.rgba(1, 1, 1, 0.12) + } + + /* Motion contract: a hover/press transition changes exactly one visual + * property over `fast` -- colour, or a single small scale/translate, + * never both, and never an overshoot easing (Easing.OutBack) on a + * hover-triggered transform. An idle animation loop (Animation.Infinite) + * is only ever gated on a real busy/live state property -- the way + * RecentItem.qml/InstanceListRow.qml gate their pulse on `isRunning`, + * PlayDock.qml on `dockRunning`, and Skeleton.qml on its own visibility + * while loading -- never bound to "the page happens to be idle" alone. + * See design-plan.md §2.2-2.4. + * + * Selection grammar: one documented look per control class, not one + * grammar total and not accidental variety -- + * - rail/list nav (SidebarNav, and anything reusing its mechanism, + * e.g. Settings' section list): a sliding surfaceRaised pill plus a + * 3px accent bar pinned to the leading edge. + * - tab strip (TabStrip): an accent underline beneath the active tab. + * - swatch/tile picker (PalettePicker and similar): an accent ring + * around the selected tile. + * - segmented control (SegmentedControl): a raised fill on the + * selected segment, no accent. + * Every instance of a class uses its class's grammar; a control never + * falls back to a bare colour-only "selected" look. See design-plan.md + * §2.6. + */ + readonly property QtObject motion: QtObject { + readonly property int fast: 120 + readonly property int normal: 180 + readonly property int slow: 260 + readonly property int easing: Easing.OutCubic + } + + // Inter 4.1 static TTFs, bundled as MeshMC.Theme module resources + // (Theme/CMakeLists.txt) rather than probed for as a system font: the + // whole type scale below is only true on a machine that happens to + // already have Inter installed, unless the app ships its own copy. + // Loaded once, here, since every consumer reaches Inter through + // Theme.font.family rather than importing a FontLoader of its own. + readonly property bool _interReady: _interRegular.status === FontLoader.Ready + && _interMedium.status === FontLoader.Ready + && _interSemiBold.status === FontLoader.Ready + && _interBold.status === FontLoader.Ready + property FontLoader _interRegular: FontLoader { + source: "qrc:/qt/qml/MeshMC/Theme/fonts/inter/Inter-Regular.ttf" + } + property FontLoader _interMedium: FontLoader { + source: "qrc:/qt/qml/MeshMC/Theme/fonts/inter/Inter-Medium.ttf" + } + property FontLoader _interSemiBold: FontLoader { + source: "qrc:/qt/qml/MeshMC/Theme/fonts/inter/Inter-SemiBold.ttf" + } + property FontLoader _interBold: FontLoader { + source: "qrc:/qt/qml/MeshMC/Theme/fonts/inter/Inter-Bold.ttf" + } + Component.onCompleted: { + if (!root._interReady) + console.warn("Theme: bundled Inter failed to load, falling back to", + Qt.application.font.family) + } + + readonly property QtObject font: QtObject { + // Unconditional now that Inter is bundled (see the FontLoaders + // above) -- the platform font is only ever used if loading the + // bundled resource itself failed, not merely because Inter isn't + // separately installed system-wide. + readonly property string family: root._interReady + ? "Inter" : Qt.application.font.family + readonly property string mono: Qt.fontFamilies().indexOf("JetBrains Mono") >= 0 + ? "JetBrains Mono" + : Qt.platform.os === "osx" ? "Menlo" + : Qt.platform.os === "windows" ? "Consolas" + : "DejaVu Sans Mono" + } + + // Contract: heading (20) and display (28) are page-header ceilings, not + // hero sizes -- a page that reads as "huge header, tiny work area" is a + // per-page layout bug to fix at the call site, never a reason to raise + // these numbers further. Caps + letter-spacing stay allowed only on + // `overline`. + readonly property QtObject type: QtObject { + readonly property QtObject caption: QtObject { + readonly property int pixelSize: 12 + readonly property int weight: Font.Normal + readonly property real lineHeight: 1.33 + // lineHeight is a multiplier for Text; this is the same line in pixels, + // for anything that has to reserve room for text before it exists. + readonly property real lineHeightPx: pixelSize * lineHeight + } + // Small uppercase section labels ("RECENT", "CONTINUE PLAYING"). + readonly property QtObject overline: QtObject { + readonly property int pixelSize: 11 + readonly property int weight: Font.DemiBold + readonly property real lineHeight: 1.30 + readonly property real letterSpacing: 0.8 + // lineHeight is a multiplier for Text; this is the same line in pixels, + // for anything that has to reserve room for text before it exists. + readonly property real lineHeightPx: pixelSize * lineHeight + } + readonly property QtObject label: QtObject { + readonly property int pixelSize: 13 + readonly property int weight: Font.Medium + readonly property real lineHeight: 1.30 + // lineHeight is a multiplier for Text; this is the same line in pixels, + // for anything that has to reserve room for text before it exists. + readonly property real lineHeightPx: pixelSize * lineHeight + } + readonly property QtObject body: QtObject { + readonly property int pixelSize: 14 + readonly property int weight: Font.Normal + readonly property real lineHeight: 1.45 + // lineHeight is a multiplier for Text; this is the same line in pixels, + // for anything that has to reserve room for text before it exists. + readonly property real lineHeightPx: pixelSize * lineHeight + } + readonly property QtObject bodyStrong: QtObject { + readonly property int pixelSize: 14 + readonly property int weight: Font.DemiBold + readonly property real lineHeight: 1.45 + // lineHeight is a multiplier for Text; this is the same line in pixels, + // for anything that has to reserve room for text before it exists. + readonly property real lineHeightPx: pixelSize * lineHeight + } + readonly property QtObject title: QtObject { + readonly property int pixelSize: 16 + readonly property int weight: Font.DemiBold + readonly property real lineHeight: 1.35 + // lineHeight is a multiplier for Text; this is the same line in pixels, + // for anything that has to reserve room for text before it exists. + readonly property real lineHeightPx: pixelSize * lineHeight + } + readonly property QtObject heading: QtObject { + readonly property int pixelSize: 20 + readonly property int weight: Font.DemiBold + readonly property real lineHeight: 1.25 + // lineHeight is a multiplier for Text; this is the same line in pixels, + // for anything that has to reserve room for text before it exists. + readonly property real lineHeightPx: pixelSize * lineHeight + } + readonly property QtObject display: QtObject { + readonly property int pixelSize: 28 + readonly property int weight: Font.Bold + readonly property real lineHeight: 1.15 + // lineHeight is a multiplier for Text; this is the same line in pixels, + // for anything that has to reserve room for text before it exists. + readonly property real lineHeightPx: pixelSize * lineHeight + } + } +} diff --git a/launcher/qml/Theme/ThemeService.cpp b/launcher/qml/Theme/ThemeService.cpp new file mode 100644 index 00000000..5665ce56 --- /dev/null +++ b/launcher/qml/Theme/ThemeService.cpp @@ -0,0 +1,110 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "qml/Theme/ThemeService.h" + +#include +#include +#include + +namespace +{ + /* QStyleHints::colorScheme() would answer this directly, but it is Qt + * 6.5+ and the floor here is 6.4. lightnessF() < 0.5 on the window colour + * is the same heuristic ui/themes/ThemeManager.cpp already uses + * (resolveIconTheme()) to tell a dark system palette from a light one. */ + bool systemPrefersDark() + { + return qGuiApp->palette().color(QPalette::Window).lightnessF() < 0.5; + } +} // namespace + +ThemeService::ThemeService(QObject* parent) + : QObject(parent) +{ + /* QGuiApplication::paletteChanged() has been deprecated in favour of + * QEvent::ApplicationPaletteChange since Qt 6.0, but it is still emitted + * on the 6.4 floor and needs no application-wide event filter just for + * this one listener. */ + QT_WARNING_PUSH + QT_WARNING_DISABLE_DEPRECATED + connect(qGuiApp, &QGuiApplication::paletteChanged, this, + &ThemeService::applySystemPalette); + QT_WARNING_POP +} + +ThemePalette ThemeService::palette() const +{ + return ThemePalette::forScheme(m_scheme, m_dark); +} + +void ThemeService::setScheme(const QString& scheme) +{ + const ThemePalette::Scheme parsed = ThemePalette::schemeFromName(scheme); + if (ThemePalette::schemeName(parsed) != scheme) { + qWarning() << "ThemeService: ignoring unknown colour scheme" << scheme; + return; + } + if (parsed == m_scheme) + return; + m_scheme = parsed; + emit changed(); +} + +ThemePalette ThemeService::previewPalette(const QString& scheme) const +{ + return ThemePalette::forScheme(ThemePalette::schemeFromName(scheme), m_dark); +} + +void ThemeService::setMode(const QString& mode) +{ + if (mode != QStringLiteral("system") && mode != QStringLiteral("dark") && + mode != QStringLiteral("light")) { + qWarning() << "ThemeService: ignoring unknown theme mode" << mode; + return; + } + if (mode == m_mode) + return; + + m_mode = mode; + if (m_mode == QStringLiteral("dark")) + m_dark = true; + else if (m_mode == QStringLiteral("light")) + m_dark = false; + else + m_dark = systemPrefersDark(); + + // The mode itself changed either way, so the whole theme is announced as + // one unit even on the rare switch that leaves `dark` unchanged (e.g. + // "system" -> "light" while the system is already light). + emit changed(); +} + +void ThemeService::applySystemPalette() +{ + if (m_mode != QStringLiteral("system")) + return; + + const bool dark = systemPrefersDark(); + if (dark == m_dark) + return; + + m_dark = dark; + emit changed(); +} diff --git a/launcher/qml/Theme/ThemeService.h b/launcher/qml/Theme/ThemeService.h new file mode 100644 index 00000000..8207a572 --- /dev/null +++ b/launcher/qml/Theme/ThemeService.h @@ -0,0 +1,91 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include + +#include "theme/ThemePalette.h" + +/* + * The C++ half of MeshMC.Theme: owns which palette is active and follows the + * OS when asked to. + * + * Registered as this module's QML singleton because Theme.qml -- the + * documented public surface every other QML file binds to -- needs exactly + * one instance to forward palette/dark/mode to. Consumers are expected to go + * through Theme.*, not this type; it is deliberately absent from the + * contract, ThemePalette's colours pass through untouched. + * + * One `changed()` signal covers palette, dark and mode's effect together, so + * a binding can never observe, say, the new `dark` with the previous + * `palette` still attached mid-switch. + */ +class ThemeService : public QObject +{ + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + Q_PROPERTY(ThemePalette palette READ palette NOTIFY changed) + Q_PROPERTY(bool dark READ dark NOTIFY changed) + Q_PROPERTY(QString mode READ mode WRITE setMode NOTIFY changed) + /* "amethyst", "ember", "diamond" or "grass" -- see ThemePalette::Scheme. */ + Q_PROPERTY(QString scheme READ scheme WRITE setScheme NOTIFY changed) + + public: + explicit ThemeService(QObject* parent = nullptr); + + ThemePalette palette() const; + bool dark() const { return m_dark; } + QString mode() const { return m_mode; } + + /* Accepts "system", "dark" or "light"; anything else is rejected with a + * warning and leaves the current mode untouched. */ + void setMode(const QString& mode); + + QString scheme() const { return ThemePalette::schemeName(m_scheme); } + /* Unknown names are rejected with a warning, like setMode(). */ + void setScheme(const QString& scheme); + + /* @p scheme's palette in the current mode, for a picker that shows + * every scheme side by side without switching to it. */ + Q_INVOKABLE ThemePalette previewPalette(const QString& scheme) const; + + signals: + void changed(); + + private: + /* Re-reads the OS palette while in "system" mode; connected to + * QGuiApplication so a live OS theme switch is picked up without a + * restart. No-op outside "system" mode, and outside a real value change. */ + void applySystemPalette(); + + /* Dark by default: the launcher is designed dark-first. "system" follows + * the OS instead, and is one setting away. */ + QString m_mode = QStringLiteral("dark"); + bool m_dark = true; + // Grass is the app's default scheme (see ThemePalette.h); this fallback + // only matters before Main.qml's own Component.onCompleted applies the + // persisted "UiPalette" setting (or for a consumer that never does, e.g. + // a unit test constructing ThemeService directly). + ThemePalette::Scheme m_scheme = ThemePalette::Scheme::Grass; +}; diff --git a/launcher/qml/Theme/Theme_test.cpp b/launcher/qml/Theme/Theme_test.cpp new file mode 100644 index 00000000..ddee8108 --- /dev/null +++ b/launcher/qml/Theme/Theme_test.cpp @@ -0,0 +1,154 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "qml/Theme/ThemeService.h" +#include "theme/ThemePalette.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +/* + * Smoke test for MeshMC.Theme, the same job QmlModule_test.cpp does for the + * MeshMC module: proves the module resolves at its resource prefix, the + * singleton instantiates, and the tokens it hands out are the ones this + * module declares -- not that any UI built on top of them looks right. + * + * The component is supplied via setData() rather than a loadFromModule()/qrc + * URL, because it only needs to *use* the module, not live inside it; a + * plain QUrl base is enough for relative resolution and needs no resource + * entry of its own. + */ +class ThemeTest : public QObject +{ + Q_OBJECT + + private slots: + void tokensAndModeSwitch() + { + QQmlEngine engine; + engine.addImportPath(QStringLiteral("qrc:/qt/qml")); + + QQmlComponent component(&engine); + component.setData(QByteArrayLiteral( + "import QtQuick\n" + "import MeshMC.Theme\n" + "QtObject {\n" + " property color accent: Theme.palette.accent\n" + " property int spaceMd: Theme.space.md\n" + " property int bodyPixelSize: Theme.type.body.pixelSize\n" + " property int radiusMd: Theme.radius.md\n" + " property bool dark: Theme.dark\n" + " property color canvasColor: Theme.palette.canvas\n" + " // Exposed so the test can reach the singletons from C++\n" + " // without QQmlEngine::singletonInstance(uri, typeName),\n" + " // which is Qt 6.5+ and this project's floor is 6.4.\n" + " property QtObject themeRef: Theme\n" + " property QtObject serviceRef: ThemeService\n" + "}\n"), + QUrl(QStringLiteral("inline"))); + + QVERIFY2(!component.isError(), qPrintable(component.errorString())); + + std::unique_ptr root(component.create()); + QVERIFY2(root != nullptr, qPrintable(component.errorString())); + + // The plain constants: exactly the values Theme.qml declares. + QCOMPARE(root->property("spaceMd").toInt(), 12); + QCOMPARE(root->property("bodyPixelSize").toInt(), 14); + QCOMPARE(root->property("radiusMd").toInt(), 8); + + // The colour tokens: not merely "some colour", but the one + // ThemePalette hands out for whichever theme is active -- proving + // the token came from ThemePalette rather than a value made up here. + // + // The launcher starts dark whatever the OS prefers -- that is the + // documented default -- so this holds on a light test runner too. + // A fresh ThemeService also starts on the Grass scheme (see its own + // m_scheme default), not Amethyst/meshDark() -- this deliberately + // does not use meshDark() here, unlike the same check further down + // for "ember", so a future default-scheme change fails loudly here + // instead of this assertion silently checking the wrong palette. + QCOMPARE(root->property("dark").toBool(), true); + QCOMPARE(root->property("accent").value(), + ThemePalette::forScheme(ThemePalette::Scheme::Grass, true).accent); + + auto* service = qobject_cast( + root->property("serviceRef").value()); + QVERIFY(service != nullptr); + QObject* themeRef = root->property("themeRef").value(); + QVERIFY(themeRef != nullptr); + + // Switching mode replaces the whole theme as one unit: `dark` and + // `palette` land on the new theme together, and `changed` fires + // exactly once per switch, never once per property. + QSignalSpy toLight(service, &ThemeService::changed); + themeRef->setProperty("mode", QStringLiteral("light")); + QCOMPARE(toLight.count(), 1); + QCOMPARE(root->property("dark").toBool(), false); + QCOMPARE(root->property("canvasColor").value(), + ThemePalette::forScheme(ThemePalette::Scheme::Grass, false).canvas); + + QSignalSpy toDark(service, &ThemeService::changed); + themeRef->setProperty("mode", QStringLiteral("dark")); + QCOMPARE(toDark.count(), 1); + QCOMPARE(root->property("dark").toBool(), true); + QCOMPARE(root->property("canvasColor").value(), + ThemePalette::forScheme(ThemePalette::Scheme::Grass, true).canvas); + + // The colour scheme swaps independently of the mode, also as one + // announced unit; an unknown name changes nothing. + QSignalSpy toEmber(service, &ThemeService::changed); + themeRef->setProperty("scheme", QStringLiteral("ember")); + QCOMPARE(toEmber.count(), 1); + QCOMPARE(root->property("accent").value(), + ThemePalette::forScheme(ThemePalette::Scheme::Ember, true).accent); + QCOMPARE(root->property("dark").toBool(), true); + + QSignalSpy ignored(service, &ThemeService::changed); + service->setScheme(QStringLiteral("nonsense")); + QCOMPARE(ignored.count(), 0); + QCOMPARE(service->scheme(), QStringLiteral("ember")); + QCOMPARE(service->previewPalette(QStringLiteral("diamond")).accent, + ThemePalette::forScheme(ThemePalette::Scheme::Diamond, true).accent); + } +}; + +int main(int argc, char* argv[]) +{ + /* Qt Quick types need a QGuiApplication, and ThemeService itself reads + * QGuiApplication::palette() -- QTEST_GUILESS_MAIN (QCoreApplication), + * what most other tests in this tree use, is not an option here. Forcing + * the offscreen platform keeps it runnable on a headless CI runner + * without depending on the harness to set QT_QPA_PLATFORM for us. */ + qputenv("QT_QPA_PLATFORM", "offscreen"); + + QGuiApplication app(argc, argv); + ThemeTest test; + return QTest::qExec(&test, argc, argv); +} + +#include "Theme_test.moc" diff --git a/launcher/qml/Theme/fonts/inter/Inter-Bold.ttf b/launcher/qml/Theme/fonts/inter/Inter-Bold.ttf new file mode 100644 index 00000000..9fb9b751 Binary files /dev/null and b/launcher/qml/Theme/fonts/inter/Inter-Bold.ttf differ diff --git a/launcher/qml/Theme/fonts/inter/Inter-Medium.ttf b/launcher/qml/Theme/fonts/inter/Inter-Medium.ttf new file mode 100644 index 00000000..458cd060 Binary files /dev/null and b/launcher/qml/Theme/fonts/inter/Inter-Medium.ttf differ diff --git a/launcher/qml/Theme/fonts/inter/Inter-Regular.ttf b/launcher/qml/Theme/fonts/inter/Inter-Regular.ttf new file mode 100644 index 00000000..b7aaca8d Binary files /dev/null and b/launcher/qml/Theme/fonts/inter/Inter-Regular.ttf differ diff --git a/launcher/qml/Theme/fonts/inter/Inter-SemiBold.ttf b/launcher/qml/Theme/fonts/inter/Inter-SemiBold.ttf new file mode 100644 index 00000000..47f8ab1d Binary files /dev/null and b/launcher/qml/Theme/fonts/inter/Inter-SemiBold.ttf differ diff --git a/launcher/qml/Theme/fonts/inter/OFL.txt b/launcher/qml/Theme/fonts/inter/OFL.txt new file mode 100644 index 00000000..9b2ca37b --- /dev/null +++ b/launcher/qml/Theme/fonts/inter/OFL.txt @@ -0,0 +1,92 @@ +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/launcher/screenshots/ImgurAlbumCreation.cpp b/launcher/screenshots/ImgurAlbumCreation.cpp index 7154c4ed..dde97f32 100644 --- a/launcher/screenshots/ImgurAlbumCreation.cpp +++ b/launcher/screenshots/ImgurAlbumCreation.cpp @@ -27,7 +27,7 @@ #include #include "BuildConfig.h" -#include "Application.h" +#include "core/LauncherContext.h" ImgurAlbumCreation::ImgurAlbumCreation(QList screenshots) : NetAction(), m_screenshots(screenshots) @@ -58,7 +58,7 @@ void ImgurAlbumCreation::startImpl() const QByteArray data = "deletehashes=" + hashes.join(',').toUtf8() + "&title=Minecraft%20Screenshots&privacy=hidden"; - QNetworkReply* rep = APPLICATION->network()->post(request, data); + QNetworkReply* rep = LAUNCHER->network()->post(request, data); m_reply.reset(rep); connect(rep, &QNetworkReply::uploadProgress, this, diff --git a/launcher/screenshots/ScreenshotListModel.cpp b/launcher/screenshots/ScreenshotListModel.cpp new file mode 100644 index 00000000..f8e037a4 --- /dev/null +++ b/launcher/screenshots/ScreenshotListModel.cpp @@ -0,0 +1,239 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "ScreenshotListModel.h" + +#include +#include +#include +#include + +#include + +namespace +{ +bool caseInsensitiveContains(const QStringList& list, const QString& value) +{ + for (const QString& entry : list) { + if (entry.compare(value, Qt::CaseInsensitive) == 0) { + return true; + } + } + return false; +} +} // namespace + +ScreenshotListModel::ScreenshotListModel(QObject* parent) + : QAbstractListModel(parent), m_watcher(new QFileSystemWatcher(this)) +{ + m_refreshTimer.setSingleShot(true); + connect(&m_refreshTimer, &QTimer::timeout, this, + &ScreenshotListModel::refreshNow); + connect(m_watcher, &QFileSystemWatcher::directoryChanged, this, + &ScreenshotListModel::onWatcherDirectoryChanged); +} + +ScreenshotListModel::~ScreenshotListModel() {} + +int ScreenshotListModel::rowCount(const QModelIndex& parent) const +{ + if (parent.isValid()) { + return 0; + } + return m_entries.size(); +} + +QVariant ScreenshotListModel::data(const QModelIndex& index, int role) const +{ + if (!index.isValid() || index.row() < 0 || + index.row() >= m_entries.size()) { + return QVariant(); + } + + const Entry& entry = m_entries.at(index.row()); + switch (role) { + case Qt::DisplayRole: + case NameRole: + return entry.name; + case PathRole: + return entry.path; + case UrlRole: + return QUrl::fromLocalFile(entry.path).toString(); + case ModifiedRole: + return entry.modifiedMs; + case SizeRole: + return entry.size; + default: + return QVariant(); + } +} + +QHash ScreenshotListModel::roleNames() const +{ + return { + {NameRole, "name"}, + {PathRole, "path"}, + {UrlRole, "url"}, + {ModifiedRole, "modified"}, + {SizeRole, "size"}, + }; +} + +void ScreenshotListModel::setDirectory(const QString& directory) +{ + const QString cleaned = + directory.isEmpty() ? QString() : QDir(directory).absolutePath(); + if (cleaned == m_directory) { + return; + } + + if (!m_watcher->directories().isEmpty()) { + m_watcher->removePaths(m_watcher->directories()); + } + m_directory = cleaned; + if (!m_directory.isEmpty() && QDir(m_directory).exists()) { + m_watcher->addPath(m_directory); + } + emit directoryChanged(); + + // A directory switch is a direct, user-visible request (opening a + // different instance's screenshots) -- refresh right away rather than + // through the debounce timer, which exists only to coalesce bursts of + // filesystem events. + m_refreshTimer.stop(); + refreshNow(); +} + +bool ScreenshotListModel::remove(int row) +{ + if (row < 0 || row >= m_entries.size()) { + return false; + } + + const QString path = m_entries.at(row).path; + + QString pathInTrash; + if (FS::trash(path, &pathInTrash)) { + qDebug() << "Screenshot" << path << "moved to trash at" + << pathInTrash; + } else if (FS::deletePath(path)) { + qDebug() << "Screenshot" << path + << "deleted outright (no trash available)"; + } else { + qWarning() << "Failed to remove screenshot" << path; + return false; + } + + beginRemoveRows(QModelIndex(), row, row); + m_entries.removeAt(row); + endRemoveRows(); + emit countChanged(); + return true; +} + +QString ScreenshotListModel::pathAt(int row) const +{ + if (row < 0 || row >= m_entries.size()) { + return QString(); + } + return m_entries.at(row).path; +} + +void ScreenshotListModel::onWatcherDirectoryChanged(const QString& path) +{ + Q_UNUSED(path); + // Restarting an already-running single-shot timer pushes its + // deadline out, so a burst of signals in under kRefreshDebounceMs + // collapses into the one refresh that follows the last of them. + m_refreshTimer.start(kRefreshDebounceMs); +} + +void ScreenshotListModel::refreshNow() +{ + QList fresh = listEntries(m_directory); + + // The watcher silently drops a path that stops existing and never + // re-adds it on its own -- re-arm it here so a directory that + // reappears (or appears for the first time) between refreshes is + // picked up by the next one. + if (!m_directory.isEmpty() && QDir(m_directory).exists() && + !m_watcher->directories().contains(m_directory)) { + m_watcher->addPath(m_directory); + } + + if (fresh == m_entries) { + return; + } + + beginResetModel(); + m_entries = std::move(fresh); + endResetModel(); + emit countChanged(); +} + +bool ScreenshotListModel::isImageFile(const QString& fileName) +{ + static const QStringList kExtensions = { + QStringLiteral("png"), + QStringLiteral("jpg"), + QStringLiteral("jpeg"), + }; + return caseInsensitiveContains(kExtensions, QFileInfo(fileName).suffix()); +} + +QList +ScreenshotListModel::listEntries(const QString& directory) +{ + QList entries; + if (directory.isEmpty()) { + return entries; + } + + QDir dir(directory); + if (!dir.exists()) { + return entries; + } + + const QFileInfoList files = + dir.entryInfoList(QDir::Files | QDir::Readable, QDir::NoSort); + entries.reserve(files.size()); + for (const QFileInfo& info : files) { + if (!isImageFile(info.fileName())) { + continue; + } + Entry entry; + entry.name = info.fileName(); + entry.path = info.absoluteFilePath(); + entry.modifiedMs = info.lastModified().toMSecsSinceEpoch(); + entry.size = info.size(); + entries.append(entry); + } + + // Newest-modified first; the name is only a tiebreak, to keep the + // order deterministic between two refreshes of files sharing an mtime. + std::sort(entries.begin(), entries.end(), + [](const Entry& a, const Entry& b) { + if (a.modifiedMs != b.modifiedMs) { + return a.modifiedMs > b.modifiedMs; + } + return a.name > b.name; + }); + + return entries; +} diff --git a/launcher/screenshots/ScreenshotListModel.h b/launcher/screenshots/ScreenshotListModel.h new file mode 100644 index 00000000..7d18881a --- /dev/null +++ b/launcher/screenshots/ScreenshotListModel.h @@ -0,0 +1,154 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include +#include + +/* + * A QAbstractListModel over one directory's image files (png/jpg/jpeg, + * not recursive), newest-modified first -- the widget-free + * replacement for the QFileSystemModel + FilterModel pair + * ui/pages/instance/ScreenshotsPage.cpp builds today. That page's + * thumbnailing (ThumbnailRunnable, the RWStorage cache) is + * deliberately not ported here: it produces QIcon/QPixmap, which are + * GUI-only types this widget-free model must not touch. A QML-facing + * thumbnail provider (image:// scheme) is expected to sit on top of the + * `path`/`modified` roles this model exposes instead. + * + * DIRECTORY. Sourced from one flat directory, set with `directory`. A + * directory that does not exist (yet) is treated as empty rather than an + * error -- e.g. an instance that has never had a screenshot taken has no + * screenshots/ folder on disk at all. This does not create the folder + * itself; whoever wires this to a real instance is expected to do that the + * same way ScreenshotsPage::openedImpl() calls FS::ensureFolderPathExists() + * today, if screenshots should be creatable from an empty state. + * + * WATCHING. A QFileSystemWatcher on `directory` drives refreshes, but the + * watcher's directoryChanged() only restarts a short single-shot debounce + * timer rather than refreshing immediately: several filesystem events in a + * quick burst (e.g. importing a batch of screenshots) collapse into one + * refresh instead of one reset per event. A refresh that finds nothing + * actually changed (same set of files, same sizes, same modification + * times) leaves the model untouched -- no reset, no signal -- so an + * unrelated touch of the directory (e.g. another file being renamed away + * from *.png) does not disturb a bound view. + * + * Only the directory itself is watched, not each file individually (unlike + * ScreenshotsPage's FilterModel, whose per-file QFileSystemWatcher entries + * are never pruned when a file goes away -- see the FIXME in + * ScreenshotsPage.cpp). A file being watched for content changes has no + * analogue here: this model only cares about which files exist and their + * mtime/size, both of which directoryChanged() already correlates with. + * + * If `directory` does not exist yet when set (or when it disappears, e.g. + * the instance folder being deleted out from under the launcher), it is + * not watched at all, since QFileSystemWatcher::addPath() silently no-ops + * on a path that is not there. Each refresh re-arms the watch once the + * directory exists again, so the very next externally-triggered refresh + * (a later setDirectory() call, for instance) picks it back up -- but nothing + * polls in between purely to notice the directory's own re-creation. + */ +class ScreenshotListModel : public QAbstractListModel +{ + Q_OBJECT + Q_PROPERTY( + QString directory READ directory WRITE setDirectory NOTIFY directoryChanged) + Q_PROPERTY(int count READ count NOTIFY countChanged) + + public: + enum Roles { + NameRole = Qt::UserRole + 1, ///< File name, with extension. + PathRole, ///< Absolute path on disk. + UrlRole, ///< "file://" URL, e.g. for Image.source. + ModifiedRole, ///< Last-modified time, ms since epoch. + SizeRole, ///< File size in bytes. + }; + Q_ENUM(Roles) + + explicit ScreenshotListModel(QObject* parent = nullptr); + ~ScreenshotListModel() override; + + int rowCount(const QModelIndex& parent = QModelIndex()) const override; + QVariant data(const QModelIndex& index, + int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + + QString directory() const + { + return m_directory; + } + void setDirectory(const QString& directory); + + int count() const + { + return m_entries.size(); + } + + /* Removes the file backing row `row` from disk: moved to the + * platform's trash when one is available (FS::trash(), so the user can + * put it back), or deleted outright otherwise -- either way logged, so + * which of the two happened is at least discoverable from the log. + * Returns false for an out-of-range row or if the filesystem operation + * itself failed; the model row is only removed on success, matching + * WorldList::deleteWorld()/ModFolderModel::deleteMods() -- a failed + * delete must not leave the model claiming a file is gone when it is + * still sitting on disk. */ + Q_INVOKABLE bool remove(int row); + /* Absolute path of the file at `row`, or an empty string if `row` is + * out of range. */ + Q_INVOKABLE QString pathAt(int row) const; + + signals: + void directoryChanged(); + void countChanged(); + + private slots: + void onWatcherDirectoryChanged(const QString& path); + void refreshNow(); + + private: + struct Entry { + QString name; + QString path; + qint64 modifiedMs = 0; + qint64 size = 0; + + bool operator==(const Entry& other) const + { + return path == other.path && modifiedMs == other.modifiedMs && + size == other.size; + } + }; + + static QList listEntries(const QString& directory); + static bool isImageFile(const QString& fileName); + + // Coalesces a burst of directoryChanged() signals into one refresh. + static constexpr int kRefreshDebounceMs = 150; + + QString m_directory; + QList m_entries; + QFileSystemWatcher* m_watcher; + QTimer m_refreshTimer; +}; diff --git a/launcher/screenshots/ScreenshotListModel_test.cpp b/launcher/screenshots/ScreenshotListModel_test.cpp new file mode 100644 index 00000000..992381d0 --- /dev/null +++ b/launcher/screenshots/ScreenshotListModel_test.cpp @@ -0,0 +1,187 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include +#include +#include +#include +#include +#include + +#include "screenshots/ScreenshotListModel.h" + +namespace +{ +bool writeFile(const QString& path, const QByteArray& contents, + const QDateTime& modified) +{ + QFile file(path); + if (!file.open(QIODevice::WriteOnly)) { + return false; + } + if (file.write(contents) != contents.size()) { + return false; + } + // Flushed before the explicit mtime is set: QFile may not push + // buffered bytes to the OS until this point, and a write landing + // after setFileTime() would bump the mtime back to "now" and defeat + // the whole point of stamping it -- explicit mtimes, rather than + // relying on creation order plus a real sleep, are what keep the + // ordering test fast and non-flaky. + if (!file.flush()) { + return false; + } + const bool timeSet = + file.setFileTime(modified, QFileDevice::FileModificationTime); + file.close(); + return timeSet; +} +} // namespace + +class ScreenshotListModelTest : public QObject +{ + Q_OBJECT + private slots: + + void ordersNewestModifiedFirstAndFiltersByExtension() + { + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + const QString dir = tempDir.path(); + + const QDateTime base = QDateTime::currentDateTime(); + QVERIFY(writeFile(QDir(dir).filePath("older.png"), "a", + base.addSecs(-60))); + QVERIFY( + writeFile(QDir(dir).filePath("newer.jpg"), "bb", base)); + // Not an image extension: must not show up at all. + QVERIFY(writeFile(QDir(dir).filePath("ignored.txt"), "c", base)); + + ScreenshotListModel model; + model.setDirectory(dir); + + QCOMPARE(model.count(), 2); + QCOMPARE(model.rowCount(), 2); + QCOMPARE(model + .data(model.index(0), ScreenshotListModel::NameRole) + .toString(), + QStringLiteral("newer.jpg")); + QCOMPARE(model + .data(model.index(1), ScreenshotListModel::NameRole) + .toString(), + QStringLiteral("older.png")); + } + + void rolesExposeExpectedData() + { + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + const QString dir = tempDir.path(); + const QDateTime modified = QDateTime::currentDateTime(); + const QString path = QDir(dir).filePath("shot.png"); + QVERIFY(writeFile(path, "hello", modified)); + const QString absolutePath = QFileInfo(path).absoluteFilePath(); + + ScreenshotListModel model; + model.setDirectory(dir); + QCOMPARE(model.count(), 1); + + const QModelIndex idx = model.index(0); + QCOMPARE( + model.data(idx, ScreenshotListModel::NameRole).toString(), + QStringLiteral("shot.png")); + QCOMPARE( + model.data(idx, ScreenshotListModel::PathRole).toString(), + absolutePath); + QCOMPARE( + model.data(idx, ScreenshotListModel::UrlRole).toString(), + QUrl::fromLocalFile(absolutePath).toString()); + QCOMPARE( + model.data(idx, ScreenshotListModel::SizeRole).toLongLong(), + Q_INT64_C(5)); + QVERIFY(model.data(idx, ScreenshotListModel::ModifiedRole) + .toLongLong() > 0); + } + + void watcherPicksUpAddedFile() + { + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + const QString dir = tempDir.path(); + + ScreenshotListModel model; + model.setDirectory(dir); + QCOMPARE(model.count(), 0); + + QVERIFY(writeFile(QDir(dir).filePath("added.png"), "x", + QDateTime::currentDateTime())); + + // The watcher + debounce timer are asynchronous; QTRY_COMPARE + // polls until the refresh has had time to fire. + QTRY_COMPARE(model.count(), 1); + QCOMPARE(model + .data(model.index(0), ScreenshotListModel::NameRole) + .toString(), + QStringLiteral("added.png")); + } + + void removeDeletesFileAndShrinksModel() + { + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + const QString dir = tempDir.path(); + const QString path = QDir(dir).filePath("gone.png"); + QVERIFY(writeFile(path, "y", QDateTime::currentDateTime())); + + ScreenshotListModel model; + model.setDirectory(dir); + QCOMPARE(model.count(), 1); + + QVERIFY(model.remove(0)); + QCOMPARE(model.count(), 0); + QVERIFY(!QFileInfo::exists(path)); + } + + void removeOutOfRangeFails() + { + ScreenshotListModel model; + QVERIFY(!model.remove(0)); + QVERIFY(!model.remove(-1)); + } + + void pathAtReturnsAbsolutePathOrEmpty() + { + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + const QString dir = tempDir.path(); + const QString path = QDir(dir).filePath("p.png"); + QVERIFY(writeFile(path, "z", QDateTime::currentDateTime())); + + ScreenshotListModel model; + model.setDirectory(dir); + QCOMPARE(model.pathAt(0), QFileInfo(path).absoluteFilePath()); + QCOMPARE(model.pathAt(1), QString()); + QCOMPARE(model.pathAt(-1), QString()); + } +}; + +QTEST_GUILESS_MAIN(ScreenshotListModelTest) + +#include "ScreenshotListModel_test.moc" diff --git a/launcher/scripts/check-plugin-independence.sh b/launcher/scripts/check-plugin-independence.sh index 95b942c9..4be89556 100755 --- a/launcher/scripts/check-plugin-independence.sh +++ b/launcher/scripts/check-plugin-independence.sh @@ -296,7 +296,12 @@ declare -a FORBIDDEN_LINK_TOKENS=( # `ninja -t targets all` is the most portable way to list every target; # we then filter to those ending in `.mmco` (or `.mmco.dll` on Windows # where CMake appends the platform suffix). -mapfile -t MMCO_TARGETS < <( +# A while-read loop, not mapfile: macOS runners ship bash 3.2, which has no +# mapfile. +MMCO_TARGETS=() +while IFS= read -r target; do + [[ -n "$target" ]] && MMCO_TARGETS+=("$target") +done < <( ninja -C "$BUILD_DIR" -t targets all 2>/dev/null \ | awk -F: '{print $1}' \ | grep -E '\.mmco$|\.mmco\.dll$|\.mmco\.so$|\.mmco\.dylib$' \ diff --git a/launcher/scripts/update.sh b/launcher/scripts/update.sh index d5fac785..946aa821 100755 --- a/launcher/scripts/update.sh +++ b/launcher/scripts/update.sh @@ -50,7 +50,7 @@ echo "Writing lst file..." # Unquoted on purpose: SRC_DIRS is a whitespace separated list. # shellcheck disable=SC2086 LC_ALL=C find $SRC_DIRS -type f \ - \( -iname \*.h -o -iname \*.cpp -o -iname \*.ui \) > "$BASE_LST_FILE.raw" + \( -iname \*.h -o -iname \*.cpp -o -iname \*.ui -o -iname \*.qml \) > "$BASE_LST_FILE.raw" if [ -n "$EXCLUDE_RE" ]; then grep -Ev "$EXCLUDE_RE" "$BASE_LST_FILE.raw" | LC_ALL=C sort > "$BASE_LST_FILE" else diff --git a/launcher/tasks/TaskWatcher.cpp b/launcher/tasks/TaskWatcher.cpp new file mode 100644 index 00000000..34eb1e64 --- /dev/null +++ b/launcher/tasks/TaskWatcher.cpp @@ -0,0 +1,158 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "TaskWatcher.h" + +TaskWatcher::TaskWatcher(Task::Ptr task, QObject* parent) + : QObject(parent), m_task(std::move(task)) +{ + m_progressFlushTimer.setSingleShot(true); + connect(&m_progressFlushTimer, &QTimer::timeout, this, + &TaskWatcher::flushProgress); + + if (!m_task) { + return; + } + + /* Picked up as they stand right now, in case the task was already + * doing something before this watcher was attached to it (started() + * only fires once, from inside start()). */ + m_status = m_task->getStatus(); + m_running = m_task->isRunning(); + m_succeeded = m_task->wasSuccessful(); + m_failed = m_task->isFinished() && !m_succeeded; + if (m_task->getTotalProgress() > 0) { + m_progress = double(m_task->getProgress()) / + double(m_task->getTotalProgress()); + } + + connect(m_task.get(), &Task::status, this, &TaskWatcher::onStatus); + connect(m_task.get(), &Task::progress, this, &TaskWatcher::onProgress); + connect(m_task.get(), &Task::succeeded, this, + &TaskWatcher::onSucceeded); + connect(m_task.get(), &Task::failed, this, &TaskWatcher::onFailed); + connect(m_task.get(), &Task::started, this, + [this] { setRunning(true); }); +} + +TaskWatcher::~TaskWatcher() {} + +void TaskWatcher::setTitle(const QString& title) +{ + if (m_title == title) { + return; + } + m_title = title; + emit titleChanged(); +} + +void TaskWatcher::setInstanceId(const QString& instanceId) +{ + if (m_instanceId == instanceId) { + return; + } + m_instanceId = instanceId; + emit instanceIdChanged(); +} + +void TaskWatcher::setRunning(bool running) +{ + if (m_running == running) { + return; + } + m_running = running; + emit runningChanged(); +} + +void TaskWatcher::onStatus(const QString& status) +{ + if (m_status == status) { + return; + } + m_status = status; + emit statusChanged(); +} + +void TaskWatcher::onProgress(qint64 current, qint64 total) +{ + setProgressValue(total > 0 ? double(current) / double(total) : -1.0); +} + +void TaskWatcher::setProgressValue(double value) +{ + m_pendingProgress = value; + + if (!m_progressThrottle.isValid() || + m_progressThrottle.elapsed() >= kProgressThrottleMs) { + m_progressThrottle.restart(); + m_progressFlushPending = false; + m_progress = value; + emit progressChanged(); + return; + } + + /* Within the throttle window: remember it and make sure a trailing + * flush is scheduled, but do not fire NOTIFY yet. */ + if (!m_progressFlushPending) { + m_progressFlushPending = true; + const int remaining = + kProgressThrottleMs - int(m_progressThrottle.elapsed()); + m_progressFlushTimer.start(qMax(0, remaining)); + } +} + +void TaskWatcher::flushProgress() +{ + if (!m_progressFlushPending) { + return; + } + m_progressFlushPending = false; + m_progressThrottle.restart(); + m_progress = m_pendingProgress; + emit progressChanged(); +} + +void TaskWatcher::onSucceeded() +{ + setRunning(false); + /* The final value is never dropped on the floor by the throttle, + * even if a flush was still pending. */ + m_progressFlushTimer.stop(); + m_progressFlushPending = false; + m_progress = 1.0; + emit progressChanged(); + + m_succeeded = true; + emit succeededChanged(); + emit finished(true); +} + +void TaskWatcher::onFailed(const QString& reason) +{ + setRunning(false); + m_progressFlushTimer.stop(); + m_progressFlushPending = false; + + m_error = reason; + emit errorChanged(); + + m_failed = true; + emit failedChanged(); + emit finished(false); +} diff --git a/launcher/tasks/TaskWatcher.h b/launcher/tasks/TaskWatcher.h new file mode 100644 index 00000000..a0e47c04 --- /dev/null +++ b/launcher/tasks/TaskWatcher.h @@ -0,0 +1,168 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include + +#include "tasks/Task.h" + +/* A QML-facing observer for one running Task. + * + * QML has no idea what a Task is - it is a plain QObject with plain + * signals, not a QML type - so a page that wants to show progress for an + * instance install (or any other core Task) needs something with + * Q_PROPERTYs it can bind to instead. This is that adapter: it watches one + * Task for its whole run and mirrors what it says into properties, plus a + * single finished(bool) signal for "the whole thing is over, here is + * whether it worked". + * + * Deliberately generic - nothing here mentions instances or Modrinth. A + * model that starts a Task (ModrinthModpackModel::install(), for + * instance) wraps it in one of these and hands the result back to QML; + * the Task itself is never exposed. + */ +class TaskWatcher : public QObject +{ + Q_OBJECT + Q_PROPERTY(QString title READ title WRITE setTitle NOTIFY titleChanged) + Q_PROPERTY(QString status READ status NOTIFY statusChanged) + Q_PROPERTY(double progress READ progress NOTIFY progressChanged) + Q_PROPERTY(bool running READ isRunning NOTIFY runningChanged) + Q_PROPERTY(bool succeeded READ succeeded NOTIFY succeededChanged) + Q_PROPERTY(bool failed READ failed NOTIFY failedChanged) + Q_PROPERTY(QString error READ error NOTIFY errorChanged) + Q_PROPERTY( + QString instanceId READ instanceId NOTIFY instanceIdChanged) + + public: + /* @p task is the Task to watch - already constructed, not necessarily + * started yet. Connections are made here, in the constructor, so that + * nothing the task does before the caller starts it is missed. */ + explicit TaskWatcher(Task::Ptr task, QObject* parent = nullptr); + ~TaskWatcher() override; + + QString title() const + { + return m_title; + } + /* Set by whoever creates the watcher - a Task has no notion of a + * user-facing title of its own. */ + void setTitle(const QString& title); + + QString status() const + { + return m_status; + } + /* 0..1, or -1 while the task has not reported a total yet (an + * indeterminate/"busy" state, same convention as Task itself: + * progress(current, total) with total <= 0). */ + double progress() const + { + return m_progress; + } + bool isRunning() const + { + return m_running; + } + bool succeeded() const + { + return m_succeeded; + } + bool failed() const + { + return m_failed; + } + /* The failure reason, if any. Empty while running or on success. */ + QString error() const + { + return m_error; + } + /* The id the new instance will have, when that is knowable ahead of + * time. It usually is not: InstanceList only settles on a final, + * deduplicated instance id once the staged directory is committed, + * after the wrapped task has already succeeded - see + * InstanceList::commitStagedInstance(). So this stays empty unless + * the creator calls setInstanceId() with something it already knows. */ + QString instanceId() const + { + return m_instanceId; + } + void setInstanceId(const QString& instanceId); + + /* The task being watched, for a caller that needs to reach it + * directly (to abort it, for instance). May be null if this watcher + * was never given one. */ + Task* task() const + { + return m_task.get(); + } + + signals: + void titleChanged(); + void statusChanged(); + void progressChanged(); + void runningChanged(); + void succeededChanged(); + void failedChanged(); + void errorChanged(); + void instanceIdChanged(); + + /* The task is over, one way or the other. Fired exactly once, right + * after succeeded/failed settle to their final values. */ + void finished(bool ok); + + private slots: + void onStatus(const QString& status); + void onProgress(qint64 current, qint64 total); + void onSucceeded(); + void onFailed(const QString& reason); + + private: + void setRunning(bool running); + void setProgressValue(double value); + void flushProgress(); + + private: + Task::Ptr m_task; + + QString m_title; + QString m_status; + double m_progress = -1.0; + bool m_running = false; + bool m_succeeded = false; + bool m_failed = false; + QString m_error; + QString m_instanceId; + + /* Progress notifications are throttled to ~10/s: a download can call + * Task::setProgress() far faster than any QML binding needs to + * repaint, and every NOTIFY firing means every binding using it + * re-evaluates. Leading value is applied immediately; anything that + * arrives before the window is up is coalesced into one trailing + * emit, so the last value is never lost. */ + static constexpr int kProgressThrottleMs = 100; + QElapsedTimer m_progressThrottle; + QTimer m_progressFlushTimer; + double m_pendingProgress = -1.0; + bool m_progressFlushPending = false; +}; diff --git a/launcher/tasks/TaskWatcher_test.cpp b/launcher/tasks/TaskWatcher_test.cpp new file mode 100644 index 00000000..a3494d86 --- /dev/null +++ b/launcher/tasks/TaskWatcher_test.cpp @@ -0,0 +1,176 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include + +#include "tasks/TaskWatcher.h" + +namespace +{ + /* A Task that does nothing on its own - executeTask() is a no-op - + * so the test can drive status/progress/success/failure by hand and + * check that TaskWatcher mirrors exactly what was driven. */ + class ScriptedTask : public Task + { + Q_OBJECT + public: + using Task::Task; + + void driveStatus(const QString& status) + { + setStatus(status); + } + void driveProgress(qint64 current, qint64 total) + { + setProgress(current, total); + } + void driveSuccess() + { + emitSucceeded(); + } + void driveFailure(const QString& reason) + { + emitFailed(reason); + } + + protected: + void executeTask() override {} + }; +} // namespace + +class TaskWatcherTest : public QObject +{ + Q_OBJECT + + private slots: + + void test_MirrorsStatusAndProgress() + { + auto* task = new ScriptedTask(); + TaskWatcher watcher{Task::Ptr(task)}; + + QSignalSpy statusSpy(&watcher, &TaskWatcher::statusChanged); + QSignalSpy progressSpy(&watcher, &TaskWatcher::progressChanged); + + QCOMPARE(watcher.isRunning(), false); + task->start(); + QCOMPARE(watcher.isRunning(), true); + + task->driveStatus("Downloading pack.mrpack"); + QCOMPARE(watcher.status(), QString("Downloading pack.mrpack")); + QCOMPARE(statusSpy.count(), 1); + + task->driveProgress(50, 100); + QCOMPARE(watcher.progress(), 0.5); + QCOMPARE(progressSpy.count(), 1); + } + + void test_IndeterminateProgressIsMinusOne() + { + auto* task = new ScriptedTask(); + TaskWatcher watcher{Task::Ptr(task)}; + task->start(); + + task->driveProgress(0, 0); + QCOMPARE(watcher.progress(), -1.0); + } + + void test_ProgressNotifyIsThrottled() + { + auto* task = new ScriptedTask(); + TaskWatcher watcher{Task::Ptr(task)}; + task->start(); + + QSignalSpy progressSpy(&watcher, &TaskWatcher::progressChanged); + + /* The first update always goes straight through - there is + * nothing to coalesce with yet. */ + task->driveProgress(1, 100); + QCOMPARE(progressSpy.count(), 1); + + /* A burst right behind it must not turn into a burst of NOTIFY + * firings; the throttle should coalesce them into (at most) one + * more. */ + for (int i = 2; i <= 20; ++i) { + task->driveProgress(i, 100); + } + QVERIFY(progressSpy.count() <= 2); + + /* But the last value reported is never lost - it shows up once + * the throttle window has had time to flush. */ + QTRY_COMPARE_WITH_TIMEOUT(watcher.progress(), 0.20, 1000); + } + + void test_Succeeds() + { + auto* task = new ScriptedTask(); + TaskWatcher watcher{Task::Ptr(task)}; + QSignalSpy finishedSpy(&watcher, &TaskWatcher::finished); + + task->start(); + task->driveProgress(3, 10); + task->driveSuccess(); + + QCOMPARE(watcher.isRunning(), false); + QCOMPARE(watcher.succeeded(), true); + QCOMPARE(watcher.failed(), false); + QCOMPARE(watcher.progress(), 1.0); + QCOMPARE(finishedSpy.count(), 1); + QCOMPARE(finishedSpy.at(0).at(0).toBool(), true); + } + + void test_Fails() + { + auto* task = new ScriptedTask(); + TaskWatcher watcher{Task::Ptr(task)}; + QSignalSpy finishedSpy(&watcher, &TaskWatcher::finished); + + task->start(); + task->driveFailure("network is on fire"); + + QCOMPARE(watcher.isRunning(), false); + QCOMPARE(watcher.succeeded(), false); + QCOMPARE(watcher.failed(), true); + QCOMPARE(watcher.error(), QString("network is on fire")); + QCOMPARE(finishedSpy.count(), 1); + QCOMPARE(finishedSpy.at(0).at(0).toBool(), false); + } + + void test_TitleAndInstanceIdAreSettableByCreator() + { + auto* task = new ScriptedTask(); + TaskWatcher watcher{Task::Ptr(task)}; + + QSignalSpy titleSpy(&watcher, &TaskWatcher::titleChanged); + watcher.setTitle("Installing Vault Hunters"); + QCOMPARE(watcher.title(), QString("Installing Vault Hunters")); + QCOMPARE(titleSpy.count(), 1); + + QCOMPARE(watcher.instanceId(), QString()); + QSignalSpy idSpy(&watcher, &TaskWatcher::instanceIdChanged); + watcher.setInstanceId("abc123"); + QCOMPARE(watcher.instanceId(), QString("abc123")); + QCOMPARE(idSpy.count(), 1); + } +}; + +QTEST_GUILESS_MAIN(TaskWatcherTest) + +#include "TaskWatcher_test.moc" diff --git a/launcher/theme/Contrast.cpp b/launcher/theme/Contrast.cpp new file mode 100644 index 00000000..72aea3e0 --- /dev/null +++ b/launcher/theme/Contrast.cpp @@ -0,0 +1,76 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "theme/Contrast.h" + +#include + +namespace Contrast +{ + + namespace + { + /* WCAG 2.1 SC 1.4.3: linearises a single sRGB channel already + * normalised to [0, 1]. The 0.03928 breakpoint and the two branches + * either side of it come straight from the spec -- there is no + * simpler formula that covers both of them at once. */ + qreal linearize(qreal channel) + { + return channel <= 0.03928 ? channel / 12.92 + : qPow((channel + 0.055) / 1.055, 2.4); + } + } + + qreal relativeLuminance(const QColor& color) + { + return 0.2126 * linearize(color.redF()) + + 0.7152 * linearize(color.greenF()) + + 0.0722 * linearize(color.blueF()); + } + + qreal ratio(const QColor& a, const QColor& b) + { + const qreal la = relativeLuminance(a); + const qreal lb = relativeLuminance(b); + const qreal lighter = qMax(la, lb); + const qreal darker = qMin(la, lb); + return (lighter + 0.05) / (darker + 0.05); + } + + bool meetsAA(const QColor& a, const QColor& b, bool largeText) + { + return ratio(a, b) >= (largeText ? 3.0 : 4.5); + } + + bool meetsAAA(const QColor& a, const QColor& b, bool largeText) + { + return ratio(a, b) >= (largeText ? 4.5 : 7.0); + } + + QColor compositeOver(const QColor& foreground, const QColor& background) + { + const qreal alpha = foreground.alphaF(); + const qreal behind = 1.0 - alpha; + return QColor::fromRgbF( + foreground.redF() * alpha + background.redF() * behind, + foreground.greenF() * alpha + background.greenF() * behind, + foreground.blueF() * alpha + background.blueF() * behind); + } + +} // namespace Contrast diff --git a/launcher/theme/Contrast.h b/launcher/theme/Contrast.h new file mode 100644 index 00000000..ba53e274 --- /dev/null +++ b/launcher/theme/Contrast.h @@ -0,0 +1,87 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +/* + * WCAG 2.1 contrast math for the token-based QML theme. + * + * Every colour token the theme exposes has to clear a minimum contrast + * against whatever it sits on, and the ratios written by hand into design + * notes for this project have already been wrong more than once. So a ratio + * is never a table entry here: it is computed from the WCAG formula and + * asserted against in Contrast_test.cpp instead of transcribed. + * + * Free functions rather than a class, because there is no state to hold -- + * only a pure conversion from colour to luminance and from luminance to + * ratio. QtGui only: nothing here may reach into QtWidgets or ui/, so both + * the core and the QML front end can use it. + */ +namespace Contrast +{ + + /** + * WCAG 2.1 relative luminance of @p color, in [0, 1]. + * + * Ignores alpha -- a translucent colour has no luminance of its own + * until it is known what it sits on. Run it through compositeOver() + * first if it carries transparency. + */ + qreal relativeLuminance(const QColor& color); + + /** + * WCAG 2.1 contrast ratio between @p a and @p b, in [1, 21]. + * + * The lighter of the two colours is always the numerator, so the result + * does not depend on argument order: ratio(a, b) == ratio(b, a). + */ + qreal ratio(const QColor& a, const QColor& b); + + /** + * Whether @p a on @p b clears the WCAG AA minimum: 4.5:1, or 3:1 when + * @p largeText (WCAG's definition of large: 18pt+, or 14pt+ bold -- + * classifying a token's text as one or the other is left to the caller, + * since this header has no notion of font metrics). + */ + bool meetsAA(const QColor& a, const QColor& b, bool largeText = false); + + /** + * Whether @p a on @p b clears the stricter WCAG AAA minimum: 7:1, or + * 4.5:1 when @p largeText. + */ + bool meetsAAA(const QColor& a, const QColor& b, bool largeText = false); + + /** + * Flattens @p foreground onto @p background using @p foreground's own + * alpha (standard "over" compositing), so the result is opaque and its + * contrast is actually meaningful. + * + * A token with alpha < 255 has no contrast in isolation -- the WCAG + * formula assumes two opaque colours -- so it has to be composited onto + * whatever it will actually render over before ratio(), meetsAA() or + * meetsAAA() say anything useful about it. @p background is treated as + * fully opaque regardless of its own alpha: compositing onto a second + * translucent layer would need that layer's own backdrop in turn, which + * is outside what this helper knows. + */ + QColor compositeOver(const QColor& foreground, const QColor& background); + +} // namespace Contrast diff --git a/launcher/theme/Contrast_test.cpp b/launcher/theme/Contrast_test.cpp new file mode 100644 index 00000000..af9e394d --- /dev/null +++ b/launcher/theme/Contrast_test.cpp @@ -0,0 +1,175 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include + +#include + +#include "theme/Contrast.h" + +/* + * Behavioural contract for Contrast. + * + * Design notes have hand-written the wrong WCAG ratio for a token more than + * once, so nothing here is allowed to compare against a literal that was not + * itself derived from the WCAG formula -- either the exact values (black on + * white, a colour on itself) or a pair worked out by hand in the comment + * next to the assertion. + */ + +namespace +{ + +/// Inverse of the WCAG sRGB transfer function used by Contrast::relativeLuminance(). +/// Reimplemented independently here, rather than reused from the production +/// code, so the boundary tests below do not just echo Contrast.cpp back at +/// itself. +qreal channelForLinear(qreal linear) +{ + return linear <= 0.0030186 ? linear * 12.92 + : 1.055 * qPow(linear, 1.0 / 2.4) - 0.055; +} + +/// A grey QColor(c, c, c) has relative luminance exactly equal to +/// channelForLinear's input, because the three WCAG coefficients +/// (0.2126 + 0.7152 + 0.0722) sum to 1. +QColor grayWithLuminance(qreal luminance) +{ + const qreal c = channelForLinear(luminance); + return QColor::fromRgbF(c, c, c); +} + +/// A grey against white with a chosen contrast ratio, solved from the WCAG +/// ratio formula with white's luminance fixed at 1: ratio = (1 + 0.05) / +/// (L + 0.05), so L = 1.05 / ratio - 0.05. +QColor grayOnWhiteWithRatio(qreal targetRatio) +{ + return grayWithLuminance(1.05 / targetRatio - 0.05); +} + +} // namespace + +class ContrastTest : public QObject +{ + Q_OBJECT + + private slots: + /// Black (L=0) against white (L=1): (1+0.05)/(0+0.05) = 21 exactly, up to + /// the decimal-to-binary rounding of the 0.2126/0.7152/0.0722 literals. + void test_blackOnWhiteIsMaximum() + { + QVERIFY(qAbs(Contrast::ratio(QColor(Qt::black), QColor(Qt::white)) - + 21.0) < 0.0001); + } + + /// Same pair, arguments swapped -- still 21, not 1/21. + void test_whiteOnBlackIsMaximumToo() + { + QVERIFY(qAbs(Contrast::ratio(QColor(Qt::white), QColor(Qt::black)) - + 21.0) < 0.0001); + } + + /// A colour against itself divides its own luminance by itself, so this + /// is exact regardless of what that luminance happens to be. + void test_colourAgainstItselfIsOne() + { + QCOMPARE(Contrast::ratio(QColor("#336699"), QColor("#336699")), 1.0); + QCOMPARE(Contrast::ratio(QColor(Qt::white), QColor(Qt::white)), 1.0); + QCOMPARE(Contrast::ratio(QColor(Qt::black), QColor(Qt::black)), 1.0); + } + + /// The lighter colour is always the numerator, whichever argument it + /// arrives as. + void test_ratioIsSymmetric() + { + const QColor a("#204080"); + const QColor b("#eeeecc"); + QCOMPARE(Contrast::ratio(a, b), Contrast::ratio(b, a)); + } + + /// Reference pair worked out by hand from the WCAG formula: pure red + /// (255, 0, 0) linearises R=1 to 1 and has G=B=0, so its luminance is + /// just the R coefficient, 0.2126. White's luminance is 1 (the three + /// coefficients sum to 1). ratio = (1 + 0.05) / (0.2126 + 0.05) = + /// 1.05 / 0.2626 = 3.998477 (long division to six places). + void test_knownReferencePair_redOnWhite() + { + const qreal r = Contrast::ratio(QColor(255, 0, 0), QColor(Qt::white)); + QVERIFY(qAbs(r - 3.998477) < 0.0001); + } + + /// AA normal text: 4.5:1. Margins of +-0.1 around the threshold keep the + /// check well clear of QColor's internal 16-bit-per-channel rounding + /// while still exercising the boundary rather than an arbitrary point. + void test_meetsAA_normalText_boundary() + { + const QColor white(Qt::white); + QVERIFY(Contrast::meetsAA(white, grayOnWhiteWithRatio(4.6))); + QVERIFY(!Contrast::meetsAA(white, grayOnWhiteWithRatio(4.4))); + } + + /// AA large text relaxes the same boundary to 3:1. + void test_meetsAA_largeText_boundary() + { + const QColor white(Qt::white); + QVERIFY(Contrast::meetsAA(white, grayOnWhiteWithRatio(3.1), true)); + QVERIFY(!Contrast::meetsAA(white, grayOnWhiteWithRatio(2.9), true)); + } + + /// AAA normal text: 7:1. + void test_meetsAAA_normalText_boundary() + { + const QColor white(Qt::white); + QVERIFY(Contrast::meetsAAA(white, grayOnWhiteWithRatio(7.1))); + QVERIFY(!Contrast::meetsAAA(white, grayOnWhiteWithRatio(6.9))); + } + + /// AAA large text relaxes the same boundary to 4.5:1. + void test_meetsAAA_largeText_boundary() + { + const QColor white(Qt::white); + QVERIFY(Contrast::meetsAAA(white, grayOnWhiteWithRatio(4.6), true)); + QVERIFY(!Contrast::meetsAAA(white, grayOnWhiteWithRatio(4.4), true)); + } + + /// A translucent foreground has no contrast of its own until it is + /// flattened onto what it sits on: 128/255 alpha white over black works + /// out to exactly (128, 128, 128), opaque. + void test_compositeOver_flattensTranslucentForeground() + { + const QColor result = Contrast::compositeOver( + QColor(255, 255, 255, 128), QColor(0, 0, 0)); + QCOMPARE(result.red(), 128); + QCOMPARE(result.green(), 128); + QCOMPARE(result.blue(), 128); + QCOMPARE(result.alpha(), 255); + } + + /// A fully opaque foreground has nothing behind it to blend in, so + /// compositing must be a no-op. + void test_compositeOver_opaqueForegroundIsUnchanged() + { + const QColor fg(30, 60, 90); + QCOMPARE(Contrast::compositeOver(fg, QColor(Qt::white)), fg); + } +}; + +QTEST_GUILESS_MAIN(ContrastTest) + +#include "Contrast_test.moc" diff --git a/launcher/theme/ThemePalette.cpp b/launcher/theme/ThemePalette.cpp new file mode 100644 index 00000000..a8ffe033 --- /dev/null +++ b/launcher/theme/ThemePalette.cpp @@ -0,0 +1,325 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "theme/ThemePalette.h" + +bool ThemePalette::operator==(const ThemePalette& other) const +{ + return canvas == other.canvas && surface == other.surface && + surfaceRaised == other.surfaceRaised && + surfaceOverlay == other.surfaceOverlay && + surfaceSunken == other.surfaceSunken && + + textPrimary == other.textPrimary && + textSecondary == other.textSecondary && + textTertiary == other.textTertiary && + textDisabled == other.textDisabled && + textOnAccent == other.textOnAccent && + + accent == other.accent && accentHover == other.accentHover && + accentPressed == other.accentPressed && + accentSubtle == other.accentSubtle && + accentText == other.accentText && + + border == other.border && borderStrong == other.borderStrong && + divider == other.divider && focusRing == other.focusRing && + + success == other.success && successSubtle == other.successSubtle && + warning == other.warning && warningSubtle == other.warningSubtle && + danger == other.danger && dangerSubtle == other.dangerSubtle && + info == other.info && infoSubtle == other.infoSubtle && + + hoverOverlay == other.hoverOverlay && + pressedOverlay == other.pressedOverlay && + selection == other.selection && + selectionText == other.selectionText && + + scrim == other.scrim && shadow == other.shadow && + tooltipBackground == other.tooltipBackground && + tooltipText == other.tooltipText; +} + +bool ThemePalette::operator!=(const ThemePalette& other) const +{ + return !(*this == other); +} + +namespace +{ + /* What differs between schemes. Everything else -- status colours, + * overlays, scrims -- is shared per mode, so switching scheme never + * changes what "danger" or "success" looks like. */ + struct SchemeColors + { + QColor canvas, surface, surfaceRaised, surfaceOverlay, surfaceSunken; + QColor textPrimary, textSecondary, textTertiary, textDisabled; + QColor accent, accentHover, accentPressed, accentText, textOnAccent; + QColor borderStrong, selection, selectionText, tooltipBackground; + }; + + QColor withAlpha(QColor color, int alpha) + { + color.setAlpha(alpha); + return color; + } + + ThemePalette build(const SchemeColors& c, bool dark) + { + ThemePalette p; + + p.canvas = c.canvas; + p.surface = c.surface; + p.surfaceRaised = c.surfaceRaised; + p.surfaceOverlay = c.surfaceOverlay; + p.surfaceSunken = c.surfaceSunken; + + p.textPrimary = c.textPrimary; + p.textSecondary = c.textSecondary; + p.textTertiary = c.textTertiary; + p.textDisabled = c.textDisabled; + p.textOnAccent = c.textOnAccent; + + p.accent = c.accent; + p.accentHover = c.accentHover; + p.accentPressed = c.accentPressed; + p.accentSubtle = withAlpha(c.accent, dark ? 41 : 31); // ~16% / ~12% + p.accentText = c.accentText; + + // Hairlines are translucent so they read the same over every + // surface; see the header comment for why they sit under 3:1. + // Alpha widened from 20 (~8%, measured ~1.19-1.24:1 composited over a + // panel -- barely there) to a step that composites to a visibly + // distinct edge (~1.3-1.6:1) without approaching borderStrong's own + // 3:1 floor; see ThemePalette_test.cpp's "border visible over + // surface" check. + p.border = dark ? QColor(0xFF, 0xFF, 0xFF, 38) : QColor(0x00, 0x00, 0x00, 32); + p.borderStrong = c.borderStrong; + p.divider = p.border; + // A light theme's bright accent (Ember's orange) can sit under the + // 3:1 a focus indicator needs; its darker text shade never does. + p.focusRing = dark ? c.accent : c.accentText; + + if (dark) { + p.success = QColor(0x4A, 0xDE, 0x80); + p.warning = QColor(0xFB, 0xBF, 0x24); + p.danger = QColor(0xFB, 0x71, 0x85); + p.info = QColor(0x60, 0xA5, 0xFA); + } else { + p.success = QColor(0x15, 0x6B, 0x35); + p.warning = QColor(0x8A, 0x4B, 0x06); + p.danger = QColor(0xB0, 0x10, 0x3A); + p.info = QColor(0x1D, 0x4E, 0xD8); + } + const int subtle = dark ? 41 : 31; + p.successSubtle = withAlpha(p.success, subtle); + p.warningSubtle = withAlpha(p.warning, subtle); + p.dangerSubtle = withAlpha(p.danger, subtle); + p.infoSubtle = withAlpha(p.info, subtle); + + p.hoverOverlay = dark ? QColor(0xFF, 0xFF, 0xFF, 15) : QColor(0x00, 0x00, 0x00, 13); + p.pressedOverlay = dark ? QColor(0xFF, 0xFF, 0xFF, 31) : QColor(0x00, 0x00, 0x00, 26); + p.selection = c.selection; + p.selectionText = c.selectionText; + + p.scrim = QColor(0x00, 0x00, 0x00, dark ? 140 : 115); + p.shadow = QColor(0x00, 0x00, 0x00, dark ? 102 : 51); + p.tooltipBackground = c.tooltipBackground; + p.tooltipText = dark ? c.textPrimary : QColor(0xF6, 0xF6, 0xF9); + + return p; + } + + /* Neutrals carry no hue at all, so nothing but the accent is coloured + * -- the fix for the earlier teal-grey that read as washed out. The + * violet filled surface is deep enough for white labels; lighter + * accentText is what reads as text on the dark surfaces. */ + SchemeColors amethyst(bool dark) + { + if (dark) + // canvas unchanged; surface/surfaceRaised/surfaceOverlay widened + // so the ladder reads as distinct layers instead of a near-flat + // +8/+8/+9 ramp -- see design-plan.md §3's proposed Amethyst-dark + // ramp table. + return {QColor(0x0D, 0x0D, 0x11), QColor(0x19, 0x19, 0x20), QColor(0x23, 0x23, 0x30), + QColor(0x2E, 0x2E, 0x3D), QColor(0x08, 0x08, 0x0B), + QColor(0xF4, 0xF4, 0xF7), QColor(0xB9, 0xB9, 0xC6), QColor(0x8B, 0x8B, 0x9A), + QColor(0x5C, 0x5C, 0x69), + QColor(0x76, 0x57, 0xF7), QColor(0x86, 0x6A, 0xFF), QColor(0x62, 0x44, 0xE0), + QColor(0xA9, 0x93, 0xFF), QColor(0xFF, 0xFF, 0xFF), + QColor(0x70, 0x70, 0x82), QColor(0x2B, 0x22, 0x4A), QColor(0xF4, 0xF4, 0xF7), + QColor(0x2A, 0x2A, 0x33)}; + return {QColor(0xF3, 0xF3, 0xF6), QColor(0xFA, 0xFA, 0xFC), QColor(0xFF, 0xFF, 0xFF), + QColor(0xFF, 0xFF, 0xFF), QColor(0xE8, 0xE8, 0xEE), + QColor(0x13, 0x13, 0x18), QColor(0x48, 0x48, 0x56), QColor(0x60, 0x60, 0x70), + QColor(0xA0, 0xA0, 0xAE), + QColor(0x6A, 0x4A, 0xEE), QColor(0x5B, 0x3C, 0xDC), QColor(0x4C, 0x30, 0xC2), + QColor(0x56, 0x36, 0xD6), QColor(0xFF, 0xFF, 0xFF), + QColor(0x6E, 0x6E, 0x7E), QColor(0xE6, 0xE0, 0xFF), QColor(0x2C, 0x1C, 0x7A), + QColor(0x13, 0x13, 0x18)}; + } + + /* Warm, but only just: neutrals with a trace of warmth so the orange + * belongs, without drifting into brown. Dark text on the orange -- + * white on a lava orange cannot reach 4.5:1. */ + SchemeColors ember(bool dark) + { + if (dark) + // Same widening methodology as Amethyst (design-plan.md §3, + // Wave 0b), scaled to this ramp's own warmer, tighter per-channel + // shape rather than copying Amethyst's deltas verbatim. + return {QColor(0x10, 0x0E, 0x0D), QColor(0x1B, 0x18, 0x17), QColor(0x28, 0x23, 0x21), + QColor(0x36, 0x30, 0x28), QColor(0x0A, 0x09, 0x08), + QColor(0xF7, 0xF3, 0xF0), QColor(0xC6, 0xBC, 0xB5), QColor(0x96, 0x8B, 0x85), + QColor(0x63, 0x5A, 0x55), + QColor(0xFF, 0x7A, 0x1A), QColor(0xFF, 0x8F, 0x3D), QColor(0xE8, 0x68, 0x0C), + QColor(0xFF, 0x9A, 0x52), QColor(0x1F, 0x0E, 0x02), + QColor(0x7A, 0x70, 0x6A), QColor(0x3D, 0x26, 0x16), QColor(0xF7, 0xF3, 0xF0), + QColor(0x2D, 0x28, 0x25)}; + return {QColor(0xF6, 0xF3, 0xF1), QColor(0xFC, 0xFA, 0xF9), QColor(0xFF, 0xFF, 0xFF), + QColor(0xFF, 0xFF, 0xFF), QColor(0xEC, 0xE7, 0xE3), + QColor(0x1A, 0x15, 0x12), QColor(0x54, 0x49, 0x41), QColor(0x6C, 0x60, 0x58), + QColor(0xAA, 0xA0, 0x99), + QColor(0xF2, 0x6B, 0x0F), QColor(0xFF, 0x7E, 0x24), QColor(0xD9, 0x5C, 0x06), + QColor(0xA8, 0x45, 0x00), QColor(0x1F, 0x0E, 0x02), + QColor(0x74, 0x69, 0x62), QColor(0xFF, 0xE3, 0xCC), QColor(0x5C, 0x28, 0x00), + QColor(0x1A, 0x15, 0x12)}; + } + + /* Cool navy with a bright diamond blue; like Ember, the bright filled + * surface takes dark text in the dark variant. */ + SchemeColors diamond(bool dark) + { + if (dark) + // Same widening methodology as Amethyst (design-plan.md §3, + // Wave 0b), scaled to this ramp's own looser, uneven navy shape + // rather than copying Amethyst's deltas verbatim. + return {QColor(0x0A, 0x0D, 0x14), QColor(0x12, 0x18, 0x23), QColor(0x1C, 0x25, 0x34), + QColor(0x29, 0x34, 0x48), QColor(0x06, 0x09, 0x0F), + QColor(0xEE, 0xF3, 0xFF), QColor(0xAF, 0xBA, 0xCF), QColor(0x80, 0x8C, 0xA4), + QColor(0x53, 0x5D, 0x71), + QColor(0x3D, 0x9B, 0xFF), QColor(0x5C, 0xAC, 0xFF), QColor(0x26, 0x84, 0xEA), + QColor(0x6D, 0xB4, 0xFF), QColor(0x03, 0x14, 0x29), + QColor(0x68, 0x74, 0x8C), QColor(0x14, 0x2F, 0x52), QColor(0xEE, 0xF3, 0xFF), + QColor(0x22, 0x2B, 0x3B)}; + return {QColor(0xF1, 0xF4, 0xF9), QColor(0xF9, 0xFB, 0xFE), QColor(0xFF, 0xFF, 0xFF), + QColor(0xFF, 0xFF, 0xFF), QColor(0xE5, 0xEA, 0xF2), + QColor(0x0F, 0x15, 0x22), QColor(0x45, 0x4F, 0x64), QColor(0x5C, 0x67, 0x7D), + QColor(0x9C, 0xA5, 0xB6), + QColor(0x1D, 0x6C, 0xE3), QColor(0x17, 0x5D, 0xCC), QColor(0x13, 0x4F, 0xB0), + QColor(0x16, 0x5A, 0xC4), QColor(0xFF, 0xFF, 0xFF), + QColor(0x67, 0x71, 0x84), QColor(0xDA, 0xE8, 0xFF), QColor(0x0B, 0x33, 0x75), + QColor(0x0F, 0x15, 0x22)}; + } + /* Hue-less graphite neutrals -- literally so (R == G == B at every + * step), not merely low-saturation like the other three schemes' own + * "neutrals" (Amethyst's carry a cool violet cast, Ember's a warm one, + * Diamond's a cool navy one). The user rejected an earlier attempt at + * this scheme for tinting its *surfaces* green -- a wash of "faded + * greenish tints" over every panel read as sickly, not Minecraft-like. + * Green here lives only in the accent family (accent/Hover/Pressed/ + * Subtle/Text) and the selection tint, the same place every other + * scheme keeps its own hue -- never in canvas/surface/surfaceRaised/ + * surfaceOverlay/surfaceSunken. Values re-derived and contrast-checked + * directly against Contrast::ratio()/relativeLuminance() (not + * hand-typed) before landing here; see ThemePalette_test.cpp. */ + SchemeColors grass(bool dark) + { + if (dark) + // A saturated Minecraft-grass green (the official launcher's own + // PLAY button and a grass block's top face both sit in this + // range) -- bright enough that near-black text clears 4.5:1 + // (measured 5.64:1), the same "dark text on a bright accent" + // shape Ember/Diamond's dark variants already use. + return {QColor(0x0D, 0x0D, 0x0D), QColor(0x19, 0x19, 0x19), QColor(0x23, 0x23, 0x23), + QColor(0x2E, 0x2E, 0x2E), QColor(0x08, 0x08, 0x08), + QColor(0xF4, 0xF4, 0xF4), QColor(0xB9, 0xB9, 0xB9), QColor(0x8B, 0x8B, 0x8B), + QColor(0x5C, 0x5C, 0x5C), + QColor(0x56, 0x9C, 0x3D), QColor(0x64, 0xAC, 0x48), QColor(0x3D, 0x7A, 0x28), + QColor(0x7E, 0xD9, 0x57), QColor(0x0A, 0x12, 0x06), + QColor(0x70, 0x70, 0x70), QColor(0x1E, 0x2E, 0x17), QColor(0xF4, 0xF4, 0xF4), + QColor(0x24, 0x24, 0x24)}; + // A deep enough forest green that white text clears 4.5:1 (measured + // 6.49:1) -- the light variant's own fill needs a darker green than + // the dark variant's, the same shape Amethyst/Diamond's light + // variants already use with white-on-accent. + return {QColor(0xF3, 0xF3, 0xF3), QColor(0xFA, 0xFA, 0xFA), QColor(0xFF, 0xFF, 0xFF), + QColor(0xFF, 0xFF, 0xFF), QColor(0xE8, 0xE8, 0xE8), + QColor(0x13, 0x13, 0x13), QColor(0x48, 0x48, 0x48), QColor(0x60, 0x60, 0x60), + QColor(0xA0, 0xA0, 0xA0), + // accentText a distinct, darker shade of accent rather than a + // verbatim copy -- the margin pattern Amethyst/Ember/Diamond's + // own light variants each use for their own accentText, even + // though accent alone already clears 4.5:1 as text (~6.5:1). + QColor(0x2E, 0x6B, 0x1B), QColor(0x25, 0x58, 0x16), QColor(0x1D, 0x47, 0x11), + QColor(0x24, 0x57, 0x14), QColor(0xFF, 0xFF, 0xFF), + QColor(0x60, 0x60, 0x60), QColor(0xDC, 0xED, 0xCB), QColor(0x17, 0x31, 0x10), + QColor(0x13, 0x13, 0x13)}; + } +} // namespace + +ThemePalette ThemePalette::forScheme(Scheme scheme, bool dark) +{ + switch (scheme) { + case Scheme::Ember: + return build(ember(dark), dark); + case Scheme::Diamond: + return build(diamond(dark), dark); + case Scheme::Grass: + return build(grass(dark), dark); + case Scheme::Amethyst: + break; + } + return build(amethyst(dark), dark); +} + +ThemePalette::Scheme ThemePalette::schemeFromName(const QString& name) +{ + if (name == QStringLiteral("ember")) + return Scheme::Ember; + if (name == QStringLiteral("diamond")) + return Scheme::Diamond; + if (name == QStringLiteral("grass")) + return Scheme::Grass; + return Scheme::Amethyst; +} + +QString ThemePalette::schemeName(Scheme scheme) +{ + switch (scheme) { + case Scheme::Ember: + return QStringLiteral("ember"); + case Scheme::Diamond: + return QStringLiteral("diamond"); + case Scheme::Grass: + return QStringLiteral("grass"); + case Scheme::Amethyst: + break; + } + return QStringLiteral("amethyst"); +} + +ThemePalette ThemePalette::meshDark() +{ + return forScheme(Scheme::Amethyst, true); +} + +ThemePalette ThemePalette::meshLight() +{ + return forScheme(Scheme::Amethyst, false); +} diff --git a/launcher/theme/ThemePalette.h b/launcher/theme/ThemePalette.h new file mode 100644 index 00000000..c357b970 --- /dev/null +++ b/launcher/theme/ThemePalette.h @@ -0,0 +1,205 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include + +/* + * The semantic colour tokens for the token-based QML theme -- "modern + * launcher": dark-first, soft, with a strong accent. + * + * Every field is named for what it means (textPrimary, danger, accent...), + * never for what it looks like -- there is no "cyan500" here, because a QML + * component should ask for the role a colour plays, not memorise which raw + * hue currently fills it. That is also why meshDark() and meshLight() are + * the same token set with different values rather than two unrelated + * colour lists: any component that binds to a token once works under + * either theme without caring which one is active. + * + * Q_GADGET rather than QObject: a palette carries no identity and emits no + * signals, it is a value copied around the way a QColor is, and MEMBER + * properties let QML read every token without a getter written per field. + * + * Nothing in this header hand-types a contrast ratio. What each token has + * to clear against what it sits on is asserted in ThemePalette_test.cpp + * against Contrast::ratio() and Contrast::compositeOver(), not transcribed + * into a comment where it could quietly go stale. + */ +class ThemePalette +{ + Q_GADGET + + Q_PROPERTY(QColor canvas MEMBER canvas) + Q_PROPERTY(QColor surface MEMBER surface) + Q_PROPERTY(QColor surfaceRaised MEMBER surfaceRaised) + Q_PROPERTY(QColor surfaceOverlay MEMBER surfaceOverlay) + Q_PROPERTY(QColor surfaceSunken MEMBER surfaceSunken) + + Q_PROPERTY(QColor textPrimary MEMBER textPrimary) + Q_PROPERTY(QColor textSecondary MEMBER textSecondary) + Q_PROPERTY(QColor textTertiary MEMBER textTertiary) + Q_PROPERTY(QColor textDisabled MEMBER textDisabled) + Q_PROPERTY(QColor textOnAccent MEMBER textOnAccent) + + Q_PROPERTY(QColor accent MEMBER accent) + Q_PROPERTY(QColor accentHover MEMBER accentHover) + Q_PROPERTY(QColor accentPressed MEMBER accentPressed) + Q_PROPERTY(QColor accentSubtle MEMBER accentSubtle) + Q_PROPERTY(QColor accentText MEMBER accentText) + + Q_PROPERTY(QColor border MEMBER border) + Q_PROPERTY(QColor borderStrong MEMBER borderStrong) + Q_PROPERTY(QColor divider MEMBER divider) + Q_PROPERTY(QColor focusRing MEMBER focusRing) + + Q_PROPERTY(QColor success MEMBER success) + Q_PROPERTY(QColor successSubtle MEMBER successSubtle) + Q_PROPERTY(QColor warning MEMBER warning) + Q_PROPERTY(QColor warningSubtle MEMBER warningSubtle) + Q_PROPERTY(QColor danger MEMBER danger) + Q_PROPERTY(QColor dangerSubtle MEMBER dangerSubtle) + Q_PROPERTY(QColor info MEMBER info) + Q_PROPERTY(QColor infoSubtle MEMBER infoSubtle) + + Q_PROPERTY(QColor hoverOverlay MEMBER hoverOverlay) + Q_PROPERTY(QColor pressedOverlay MEMBER pressedOverlay) + Q_PROPERTY(QColor selection MEMBER selection) + Q_PROPERTY(QColor selectionText MEMBER selectionText) + + Q_PROPERTY(QColor scrim MEMBER scrim) + Q_PROPERTY(QColor shadow MEMBER shadow) + Q_PROPERTY(QColor tooltipBackground MEMBER tooltipBackground) + Q_PROPERTY(QColor tooltipText MEMBER tooltipText) + + public: + /* Elevation, darkest/most-recessed to lightest/most-elevated in both + * themes: surfaceSunken sits below canvas (inset fields), surface holds + * ordinary panels, surfaceRaised is for cards, surfaceOverlay is for + * popovers and modals -- the layer furthest off the canvas. */ + QColor canvas; + QColor surface; + QColor surfaceRaised; + QColor surfaceOverlay; + QColor surfaceSunken; + + QColor textPrimary; + QColor textSecondary; + + /* WCAG AA only requires 3:1 for text this size or larger (18pt+, or + * 14pt+ bold) -- callers must not reach for textTertiary on ordinary + * body copy, since it is only guaranteed to clear the large-text + * minimum, not the normal-text one. */ + QColor textTertiary; + + /* No contrast minimum applies: a disabled control is meant to read as + * unavailable, not as dim body text held to the same bar. */ + QColor textDisabled; + + /* Text painted on top of a filled `accent` surface, e.g. a primary + * button's label. */ + QColor textOnAccent; + + QColor accent; + QColor accentHover; + QColor accentPressed; + + /* Tinted background, not a solid fill -- e.g. a selected filter chip. + * Translucent, so it always reads correctly composited over whatever + * surface it is painted on rather than only over one hard-coded colour. */ + QColor accentSubtle; + + /* The accent hue used AS text/icon colour directly on canvas or + * surface (a link, an active-tab label) -- kept separate from `accent` + * because a colour bright enough to read as a strong filled surface is + * not necessarily the shade that clears 4.5:1 as text on that theme's + * background, and vice versa. */ + QColor accentText; + + /* Decorative hairline. It is intentionally allowed to fall under the + * WCAG 1.4.11 non-text minimum of 3:1 (see the measured ratio in + * ThemePalette_test.cpp) because it only separates two surfaces that + * already read as distinct regions on their own, rather than carrying + * information by itself. Anything that must stay perceivable on its + * own -- an input outline, a required separator -- uses borderStrong + * instead, which the test does hold to 3:1. */ + QColor border; + QColor borderStrong; + QColor divider; + QColor focusRing; + + QColor success; + QColor successSubtle; + QColor warning; + QColor warningSubtle; + QColor danger; + QColor dangerSubtle; + QColor info; + QColor infoSubtle; + + QColor hoverOverlay; + QColor pressedOverlay; + + /* Solid fills, not translucent overlays -- a selected row's background + * and a tooltip's background need to carry their own contrast without + * depending on what happens to be behind them. */ + QColor selection; + QColor selectionText; + + /* Translucent black/white, meant to sit behind a modal (scrim) or + * under a raised surface (shadow). Neither is measured for text + * contrast -- nothing is ever read directly off them. */ + QColor scrim; + QColor shadow; + QColor tooltipBackground; + QColor tooltipText; + + /* The launcher's colour schemes. Each is one accent over its own set + * of neutrals, in a dark and a light variant; the user picks the + * scheme and the mode independently. Grass is the default (Amethyst + * was, until the purple read as the app's whole identity rather than + * one choice among several -- see ThemeService's own default). */ + enum class Scheme + { + Amethyst, // hue-less graphite, electric violet + Ember, // warm charcoal, lava orange + Diamond, // cool navy, diamond blue + Grass // hue-less graphite, Minecraft-grass green + }; + + static ThemePalette forScheme(Scheme scheme, bool dark); + /* "amethyst", "ember", "diamond" or "grass" -- the stored setting + * value. An unknown name falls back to Amethyst. */ + static Scheme schemeFromName(const QString& name); + static QString schemeName(Scheme scheme); + + /* Amethyst's two variants -- kept named "mesh" for the callers (mostly + * tests) that want a fixed, scheme-independent palette rather than + * whatever the user has picked; see forScheme() for that. */ + static ThemePalette meshDark(); + static ThemePalette meshLight(); + + bool operator==(const ThemePalette& other) const; + bool operator!=(const ThemePalette& other) const; +}; + +Q_DECLARE_METATYPE(ThemePalette) diff --git a/launcher/theme/ThemePalette_test.cpp b/launcher/theme/ThemePalette_test.cpp new file mode 100644 index 00000000..a8458222 --- /dev/null +++ b/launcher/theme/ThemePalette_test.cpp @@ -0,0 +1,217 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include + +#include "theme/Contrast.h" +#include "theme/ThemePalette.h" + +/* + * Behavioural contract for ThemePalette. + * + * Every ratio a token has to clear is computed here from Contrast::ratio() + * and Contrast::compositeOver(), the same way design notes should read + * them -- never typed by hand, because a hand-typed ratio is exactly what + * has gone stale on this project before (see Contrast_test.cpp). Every + * checked pair is also printed with qInfo() so the numbers going into + * design notes come from running this test, not from eyeballing hex codes. + */ + +namespace +{ + +/// One row of the printed ratio table plus the assertion it backs. Kept as +/// a free function rather than repeating QVERIFY2 at each call site, since +/// the same "measure, print, assert" shape recurs for every token pair +/// below. +void expectRatio(const QColor& foreground, const QColor& background, + qreal minimum, const QString& label) +{ + const qreal measured = Contrast::ratio(foreground, background); + qInfo("%-52s %6.3f (>= %.2f)", qPrintable(label), measured, minimum); + QVERIFY2(measured >= minimum, + qPrintable(QString("%1: measured %2, need >= %3") + .arg(label) + .arg(measured) + .arg(minimum))); +} + +/// Runs every contrast requirement from the ThemePalette task against one +/// theme. Shared between test_meshDark() and test_meshLight() so the two +/// themes are held to identical requirements rather than two hand-copied +/// lists that could drift apart. +void checkTheme(const ThemePalette& p, const QString& themeName) +{ + qInfo().noquote() << "----" << themeName << "----"; + + // surfaceOverlay included alongside canvas/surface/surfaceRaised: the + // widened Wave 0 ramp (design-plan.md §3) moves every layer, and the + // overlay -- popovers and modals, the layer furthest off canvas -- is the + // one a popup's own text most often sits directly on. + const QList> textSurfaces = { + { "canvas", p.canvas }, + { "surface", p.surface }, + { "surfaceRaised", p.surfaceRaised }, + { "surfaceOverlay", p.surfaceOverlay }, + }; + + for (const auto& s : textSurfaces) { + expectRatio(p.textPrimary, s.second, 4.5, + themeName + " textPrimary on " + s.first); + expectRatio(p.textSecondary, s.second, 4.5, + themeName + " textSecondary on " + s.first); + // Large/secondary text only -- see the comment on textTertiary in + // ThemePalette.h -- so 3.0 rather than the 4.5 used just above. + expectRatio(p.textTertiary, s.second, 3.0, + themeName + " textTertiary on " + s.first); + } + + // The surface ladder has to actually read as a ladder, not just clear + // text-contrast minimums: surfaceRaised must be a perceptibly different + // luminance from canvas, not a copy the eye rounds off. 0.012 is chosen + // to fail the pre-Wave-0 ramp (~0.0083-0.0088 across all three schemes' + // dark variants) and pass the widened one (~0.013-0.0142 dark, ~0.10 + // light -- see design-plan.md §3's ramp table). + { + const qreal step = + Contrast::relativeLuminance(p.surfaceRaised) - + Contrast::relativeLuminance(p.canvas); + const QString label = themeName + " surfaceRaised vs canvas luminance step"; + qInfo("%-52s %6.4f (>= 0.0120)", qPrintable(label), step); + QVERIFY2(step >= 0.012, + qPrintable(QString("%1: measured %2, need >= 0.012") + .arg(label) + .arg(step))); + } + + expectRatio(p.textOnAccent, p.accent, 4.5, + themeName + " textOnAccent on accent"); + expectRatio(p.accentText, p.canvas, 4.5, + themeName + " accentText on canvas"); + expectRatio(p.accentText, p.surface, 4.5, + themeName + " accentText on surface"); + + // selection and tooltipBackground are opaque solid fills by design + // (see ThemePalette.h), so they carry their own contrast without + // needing to be composited onto anything first. + expectRatio(p.selectionText, p.selection, 4.5, + themeName + " selectionText on selection"); + expectRatio(p.tooltipText, p.tooltipBackground, 4.5, + themeName + " tooltipText on tooltipBackground"); + + // Every "Subtle" token is a translucent tint (see ThemePalette.h), so + // it has no contrast of its own until it is flattened onto the surface + // it is drawn over -- Contrast::compositeOver() does that flattening + // before the status colour is measured against it as text. + const QList> statusOnSubtle = { + { p.success, p.successSubtle }, + { p.warning, p.warningSubtle }, + { p.danger, p.dangerSubtle }, + { p.info, p.infoSubtle }, + }; + const QList statusNames = { "success", "warning", "danger", + "info" }; + for (int i = 0; i < statusOnSubtle.size(); ++i) { + const QColor subtleOverSurface = + Contrast::compositeOver(statusOnSubtle[i].second, p.surface); + expectRatio(statusOnSubtle[i].first, subtleOverSurface, 4.5, + themeName + " " + statusNames[i] + " on " + + statusNames[i] + "Subtle over surface"); + } + + // WCAG 1.4.11 non-text contrast: focusRing and borderStrong must stay + // perceivable against both backgrounds a control might sit on. + const QList> nonTextSurfaces = { + { "canvas", p.canvas }, + { "surface", p.surface }, + }; + for (const auto& s : nonTextSurfaces) { + expectRatio(p.borderStrong, s.second, 3.0, + themeName + " borderStrong on " + s.first); + expectRatio(p.focusRing, s.second, 3.0, + themeName + " focusRing on " + s.first); + + // border is decorative and explicitly allowed to fall under the 3:1 + // WCAG non-text minimum (see ThemePalette.h) -- borderStrong above is + // what carries that bar. But Wave 0's own fix was raising border's + // alpha because the old 20/255 (~1.19-1.24:1 composited) was too + // faint to read as an edge at all; 1.28 is a much lower bar than 3:1, + // chosen only to hold that fix in place and fail the old alpha. + const QColor borderOverSurface = + Contrast::compositeOver(p.border, s.second); + const qreal borderRatio = + Contrast::ratio(borderOverSurface, s.second); + const QString borderLabel = themeName + " border visible over " + s.first; + qInfo("%-52s %6.3f (>= 1.28, decorative, not WCAG 3:1)", + qPrintable(borderLabel), borderRatio); + QVERIFY2(borderRatio >= 1.28, + qPrintable(QString("%1: measured %2, need >= 1.28") + .arg(borderLabel) + .arg(borderRatio))); + } +} + +} // namespace + +class ThemePaletteTest : public QObject +{ + Q_OBJECT + + private slots: + void test_meshDark() { checkTheme(ThemePalette::meshDark(), "meshDark"); } + + void test_meshLight() + { + checkTheme(ThemePalette::meshLight(), "meshLight"); + } + + /// Every scheme, in both modes, clears the same bars as the default. + void test_everyScheme() + { + using S = ThemePalette::Scheme; + for (S scheme : {S::Amethyst, S::Ember, S::Diamond, S::Grass}) { + const QString name = ThemePalette::schemeName(scheme); + checkTheme(ThemePalette::forScheme(scheme, true), + qPrintable(name + QStringLiteral(" dark"))); + checkTheme(ThemePalette::forScheme(scheme, false), + qPrintable(name + QStringLiteral(" light"))); + QCOMPARE(ThemePalette::schemeFromName(name), scheme); + } + QCOMPARE(ThemePalette::schemeFromName(QStringLiteral("nonsense")), + S::Amethyst); + } + + /// The two themes have to disagree somewhere, and a palette has to + /// agree with an identical copy of itself -- the two ends of what + /// operator==/operator!= are for. + void test_equality() + { + const ThemePalette dark = ThemePalette::meshDark(); + const ThemePalette light = ThemePalette::meshLight(); + QVERIFY(dark != light); + + const ThemePalette darkCopy = dark; + QVERIFY(darkCopy == dark); + QVERIFY(!(darkCopy != dark)); + } +}; + +QTEST_GUILESS_MAIN(ThemePaletteTest) + +#include "ThemePalette_test.moc" diff --git a/launcher/translations/TranslationsModel.cpp b/launcher/translations/TranslationsModel.cpp index 96c29ba4..52bd6fe5 100644 --- a/launcher/translations/TranslationsModel.cpp +++ b/launcher/translations/TranslationsModel.cpp @@ -34,7 +34,7 @@ #include "POTranslator.h" -#include "Application.h" +#include "core/LauncherContext.h" const static QLatin1String defaultLangCode("en_US"); @@ -526,9 +526,9 @@ void TranslationsModel::downloadIndex() return; } qDebug() << "Downloading Translations Index..."; - d->m_index_job = new NetJob("Translations Index", APPLICATION->network()); + d->m_index_job = new NetJob("Translations Index", LAUNCHER->network()); MetaEntryPtr entry = - APPLICATION->metacache()->resolveEntry("translations", "index_v2.json"); + LAUNCHER->metacache()->resolveEntry("translations", "index_v2.json"); entry->setStale(true); d->m_index_task = Net::Download::makeCached( QUrl("https://i18n.projecttick.org/index_v2.json"), entry); @@ -569,7 +569,7 @@ void TranslationsModel::downloadTranslation(QString key) } d->m_downloadingTranslation = key; - MetaEntryPtr entry = APPLICATION->metacache()->resolveEntry( + MetaEntryPtr entry = LAUNCHER->metacache()->resolveEntry( "translations", "mmc_" + key + ".qm"); entry->setStale(true); @@ -580,7 +580,7 @@ void TranslationsModel::downloadTranslation(QString key) new Net::ChecksumValidator(QCryptographicHash::Sha1, rawHash)); dl->m_total_progress = lang->file_size; - d->m_dl_job = new NetJob("Translation for " + key, APPLICATION->network()); + d->m_dl_job = new NetJob("Translation for " + key, LAUNCHER->network()); d->m_dl_job->addNetAction(dl); connect(d->m_dl_job.get(), &NetJob::succeeded, this, diff --git a/launcher/translations/TranslationsModel_test.cpp b/launcher/translations/TranslationsModel_test.cpp new file mode 100644 index 00000000..423d5e81 --- /dev/null +++ b/launcher/translations/TranslationsModel_test.cpp @@ -0,0 +1,78 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include + +#include "translations/TranslationsModel.h" + +/* + * Covers TranslationsModel::roleNames() -- the QML-facing addition + * QmlShell::languages() hands to the QML shell's own onboarding language + * picker (see QmlShell.h's languages Q_PROPERTY comment) -- without touching + * the network (downloadIndex() is never called): the constructor's + * reloadLocalFiles() only reads a local, empty directory here, same as + * IconList_test.cpp does for IconList::roleNames(). + */ +class TranslationsModelTest : public QObject +{ + Q_OBJECT + + private slots: + void init() + { + m_dir = std::make_unique(); + QVERIFY(m_dir->isValid()); + m_model = std::make_unique(m_dir->path()); + } + + void cleanup() + { + m_model.reset(); + m_dir.reset(); + } + + void roleNamesExposeLanguageKeyNameAndCompleteness() + { + const auto roles = m_model->roleNames(); + QCOMPARE(roles.value(Qt::UserRole), QByteArray("languageKey")); + QCOMPARE(roles.value(TranslationsModel::NameRole), QByteArray("name")); + QCOMPARE(roles.value(TranslationsModel::CompletenessRole), + QByteArray("completeness")); + } + + void builtinLanguageIsSelectedByDefault() + { + // Only "en_US" exists until an index download or local files add + // more (see reloadLocalFiles()) -- so the model starts with exactly + // one row, and its languageKey role is that default. + QCOMPARE(m_model->rowCount(), 1); + const QModelIndex index = m_model->index(0); + QCOMPARE(m_model->data(index, Qt::UserRole).toString(), + QStringLiteral("en_US")); + } + + private: + std::unique_ptr m_dir; + std::unique_ptr m_model; +}; + +QTEST_GUILESS_MAIN(TranslationsModelTest) + +#include "TranslationsModel_test.moc" diff --git a/launcher/FastFileIconProvider.cpp b/launcher/ui/FastFileIconProvider.cpp similarity index 97% rename from launcher/FastFileIconProvider.cpp rename to launcher/ui/FastFileIconProvider.cpp index b956383f..9bbc18f2 100644 --- a/launcher/FastFileIconProvider.cpp +++ b/launcher/ui/FastFileIconProvider.cpp @@ -17,7 +17,7 @@ * limitations under the License. */ -#include "FastFileIconProvider.h" +#include "ui/FastFileIconProvider.h" #include #include diff --git a/launcher/FastFileIconProvider.h b/launcher/ui/FastFileIconProvider.h similarity index 100% rename from launcher/FastFileIconProvider.h rename to launcher/ui/FastFileIconProvider.h diff --git a/launcher/ui/GuiUtil.cpp b/launcher/ui/GuiUtil.cpp index b1b52b3c..1de069a5 100644 --- a/launcher/ui/GuiUtil.cpp +++ b/launcher/ui/GuiUtil.cpp @@ -42,7 +42,7 @@ QString GuiUtil::uploadPaste(const QString& text, QWidget* parentWidget) APIKeySetting = BuildConfig.PASTE_EE_KEY; } std::unique_ptr paste( - new PasteUpload(parentWidget, text, APIKeySetting)); + new PasteUpload(text, APIKeySetting)); if (!paste->validateText()) { CustomMessageBox::selectable( diff --git a/launcher/InstancePageProvider.h b/launcher/ui/InstancePageProvider.h similarity index 93% rename from launcher/InstancePageProvider.h rename to launcher/ui/InstancePageProvider.h index ae5ebefc..cd3e6ef1 100644 --- a/launcher/InstancePageProvider.h +++ b/launcher/ui/InstancePageProvider.h @@ -126,6 +126,13 @@ class InstancePageProvider : public QObject, public BasePageProvider evt.instance_handle = inst.get(); APPLICATION->pluginManager()->dispatchHook( MMCO_HOOK_UI_INSTANCE_PAGES, &evt); + + /* ABI 5 — declarative UI surfaces (ui_surface_create with + * MMCO_UI_ANCHOR_INSTANCE_PAGE) anchored to this instance + * become their own pages here too, alongside whatever the + * raw hook above still adds the BasePage-subclassing way. */ + values.append( + APPLICATION->pluginManager()->createInstancePages(inst->id())); } return values; diff --git a/launcher/ui/InstanceWindow.cpp b/launcher/ui/InstanceWindow.cpp index f2e81ea5..f0441b1d 100644 --- a/launcher/ui/InstanceWindow.cpp +++ b/launcher/ui/InstanceWindow.cpp @@ -32,7 +32,7 @@ #include "ui/dialogs/ProgressDialog.h" #include "ui/widgets/PageContainer.h" -#include "InstancePageProvider.h" +#include "ui/InstancePageProvider.h" #include "icons/IconList.h" diff --git a/launcher/ui/InstanceWindow.h b/launcher/ui/InstanceWindow.h index 2fb7c38f..d829bb07 100644 --- a/launcher/ui/InstanceWindow.h +++ b/launcher/ui/InstanceWindow.h @@ -23,7 +23,7 @@ #include #include -#include "LaunchController.h" +#include "ui/LaunchController.h" #include "launch/LaunchTask.h" #include "ui/pages/BasePageContainer.h" diff --git a/launcher/JavaCommon.cpp b/launcher/ui/JavaCommon.cpp similarity index 87% rename from launcher/JavaCommon.cpp rename to launcher/ui/JavaCommon.cpp index 0eee0efb..56f44d7d 100644 --- a/launcher/JavaCommon.cpp +++ b/launcher/ui/JavaCommon.cpp @@ -17,25 +17,33 @@ * limitations under the License. */ -#include "JavaCommon.h" +#include "ui/JavaCommon.h" #include "ui/dialogs/CustomMessageBox.h" #include #include -bool JavaCommon::checkJVMArgs(QString jvmargs, QWidget* parent) +QString JavaCommon::jvmArgsWarning(const QString& jvmargs) { if (jvmargs.contains("-XX:PermSize=") || jvmargs.contains(QRegularExpression("-Xm[sx]")) || jvmargs.contains("-XX-MaxHeapSize") || jvmargs.contains("-XX:InitialHeapSize")) { - auto warnStr = - QObject::tr("You tried to manually set a JVM memory option (using " - "\"-XX:PermSize\", \"-XX-MaxHeapSize\", " - "\"-XX:InitialHeapSize\", \"-Xmx\" or \"-Xms\").\n" - "There are dedicated boxes for these in the settings " - "(Java tab, in the Memory group at the top).\n" - "This message will be displayed until you remove them " - "from the JVM arguments."); + return QObject::tr( + "You tried to manually set a JVM memory option (using " + "\"-XX:PermSize\", \"-XX-MaxHeapSize\", " + "\"-XX:InitialHeapSize\", \"-Xmx\" or \"-Xms\").\n" + "There are dedicated boxes for these in the settings " + "(Java tab, in the Memory group at the top).\n" + "This message will be displayed until you remove them " + "from the JVM arguments."); + } + return QString(); +} + +bool JavaCommon::checkJVMArgs(QString jvmargs, QWidget* parent) +{ + const QString warnStr = jvmArgsWarning(jvmargs); + if (!warnStr.isEmpty()) { CustomMessageBox::selectable(parent, QObject::tr("JVM arguments warning"), warnStr, QMessageBox::Warning) diff --git a/launcher/JavaCommon.h b/launcher/ui/JavaCommon.h similarity index 78% rename from launcher/JavaCommon.h rename to launcher/ui/JavaCommon.h index 7232ce3b..11bc7c92 100644 --- a/launcher/JavaCommon.h +++ b/launcher/ui/JavaCommon.h @@ -27,6 +27,15 @@ class QWidget; */ namespace JavaCommon { + /* Non-UI half of checkJVMArgs() below: returns the warning text if + * @p jvmargs sets a memory option through flags meant to be set via + * the Memory group in Settings instead ("-Xmx", "-XX:PermSize=", ...), + * or an empty string if the args are fine. Free-standing so a caller + * that is not a QWidget -- LaunchController, which has to work under + * either user interface -- can show the warning through UiHost instead + * of the QMessageBox checkJVMArgs() below always shows. */ + QString jvmArgsWarning(const QString& jvmargs); + bool checkJVMArgs(QString args, QWidget* parent); // Show a dialog saying that the Java binary was not usable diff --git a/launcher/LaunchController.cpp b/launcher/ui/LaunchController.cpp similarity index 69% rename from launcher/LaunchController.cpp rename to launcher/ui/LaunchController.cpp index 3f3d5fcd..46af0c09 100644 --- a/launcher/LaunchController.cpp +++ b/launcher/ui/LaunchController.cpp @@ -17,22 +17,22 @@ * limitations under the License. */ -#include "LaunchController.h" +#include "ui/LaunchController.h" #include "minecraft/auth/AccountList.h" #include "Application.h" +#include "core/LauncherContext.h" +#include "core/UiHost.h" #include "plugin/PluginManager.h" #include "plugin/PluginHooks.h" #include "ui/MainWindow.h" #include "ui/InstanceWindow.h" -#include "ui/dialogs/CustomMessageBox.h" #include "ui/dialogs/ProfileSelectDialog.h" #include "ui/dialogs/ProgressDialog.h" #include "ui/dialogs/EditAccountDialog.h" #include "ui/dialogs/ProfileSetupDialog.h" -#include -#include +#include #include #include #include @@ -40,7 +40,7 @@ #include #include "BuildConfig.h" -#include "JavaCommon.h" +#include "ui/JavaCommon.h" #include "tasks/Task.h" #include "minecraft/auth/AccountTask.h" #include "launch/steps/CreateBackup.h" @@ -55,8 +55,13 @@ void LaunchController::executeTask() return; } - JavaCommon::checkJVMArgs(m_instance->settings()->get("JvmArgs").toString(), - m_parentWidget); + const QString jvmArgsWarning = JavaCommon::jvmArgsWarning( + m_instance->settings()->get("JvmArgs").toString()); + if (!jvmArgsWarning.isEmpty()) { + LAUNCHER->uiHost()->message(tr("JVM arguments warning"), + jvmArgsWarning, + UiHost::Severity::Warning); + } login(); } @@ -87,26 +92,35 @@ void LaunchController::decideAccount() if (accounts->count() <= 0) { // Tell the user they need to log in at least one account in order to // play. - auto reply = - CustomMessageBox::selectable( - m_parentWidget, tr("No Accounts"), - tr("In order to play Minecraft, you must have at least one " - "Microsoft " - "account logged in." - "Would you like to open the account manager to add an " - "account now?"), - QMessageBox::Information, QMessageBox::Yes | QMessageBox::No) - ->exec(); - - if (reply == QMessageBox::Yes) { + const bool wantsAccountManager = LAUNCHER->uiHost()->confirm( + tr("No Accounts"), + tr("In order to play Minecraft, you must have at least one " + "Microsoft " + "account logged in." + "Would you like to open the account manager to add an " + "account now?"), + UiHost::Severity::Information); + + if (wantsAccountManager) { // Open the account manager. - APPLICATION->ShowGlobalSettings(m_parentWidget, "accounts"); + if (APPLICATION->usingQmlShell()) { + /* ShowGlobalSettings(m_parentWidget, "accounts") would open + * the classic PageDialog with a null parent under QML + * (m_parentWidget defaults to null and QML never sets it) -- + * the same defect class as the createInstanceRequested + * SIGSEGV. QML has its own Accounts page but nothing here + * can switch it there, so just say where to go. */ + LAUNCHER->uiHost()->message( + tr("No Accounts"), + tr("Use the Accounts page in the sidebar to sign in."), + UiHost::Severity::Information); + } else { + APPLICATION->ShowGlobalSettings(m_parentWidget, "accounts"); + } } else { // Offer demo mode as an alternative - QMessageBox demoBox(m_parentWidget); - demoBox.setWindowTitle(tr("No Account — Play Demo?")); - demoBox.setIcon(QMessageBox::Question); - demoBox.setText( + const bool wantsDemo = LAUNCHER->uiHost()->confirm( + tr("No Account — Play Demo?"), tr("No Microsoft account is linked.

" "Without a Microsoft account you cannot play the full " "version of Minecraft.

" @@ -117,29 +131,22 @@ void LaunchController::decideAccount() " • Progress is not saved after the demo " "ends
" " • Multiplayer is not available

" - "Would you like to launch Minecraft in Demo Mode?")); - auto yesButton = - demoBox.addButton(tr("Play Demo"), QMessageBox::YesRole); - auto noButton = - demoBox.addButton(tr("Cancel"), QMessageBox::NoRole); - demoBox.setDefaultButton(noButton); - demoBox.exec(); - - if (demoBox.clickedButton() == yesButton) { - bool ok = false; - QString username = QInputDialog::getText( - m_parentWidget, tr("Demo Mode — Choose Username"), - tr("Enter a username to use in Demo Mode:"), - QLineEdit::Normal, tr("User"), &ok); - if (!ok) { + "Would you like to launch Minecraft in Demo Mode?"), + UiHost::Severity::Question, tr("Play Demo"), tr("Cancel")); + + if (wantsDemo) { + auto username = LAUNCHER->uiHost()->askText( + tr("Demo Mode — Choose Username"), + tr("Enter a username to use in Demo Mode:"), tr("User")); + if (!username) { // User cancelled username dialog → abort (login() will // handle the failure) return; } m_demoMode = true; - m_demoUsername = username.trimmed().isEmpty() + m_demoUsername = username->trimmed().isEmpty() ? tr("User") - : username.trimmed(); + : username->trimmed(); } else { // User declined demo mode → abort (login() will handle the // failure) @@ -161,18 +168,38 @@ void LaunchController::decideAccount() m_accountToUse = accounts->defaultAccount(); if (!m_accountToUse) { // If no default account is set, ask the user which one to use. - ProfileSelectDialog selectDialog( - tr("Which account would you like to use?"), - ProfileSelectDialog::GlobalDefaultCheckbox, m_parentWidget); + if (APPLICATION->usingQmlShell()) { + /* Plain choose() by account name -- QML has no equivalent of + * the widget dialog's "use as global default" checkbox below, + * so picking an account here never changes the default. Built + * from at(i) rather than accounts->profileNames(), which skips + * accounts with no profile name and would leave the chosen + * index pointing at the wrong account. */ + QStringList names; + names.reserve(accounts->count()); + for (int i = 0; i < accounts->count(); ++i) { + names.append(accounts->at(i)->accountDisplayString()); + } + const int index = LAUNCHER->uiHost()->choose( + tr("Which account would you like to use?"), QString(), + UiHost::Severity::Question, names); + if (index >= 0) { + m_accountToUse = accounts->at(index); + } + } else { + ProfileSelectDialog selectDialog( + tr("Which account would you like to use?"), + ProfileSelectDialog::GlobalDefaultCheckbox, m_parentWidget); - selectDialog.exec(); + selectDialog.exec(); - // Launch the instance with the selected account. - m_accountToUse = selectDialog.selectedAccount(); + // Launch the instance with the selected account. + m_accountToUse = selectDialog.selectedAccount(); - // If the user said to use the account as default, do that. - if (selectDialog.useAsGlobalDefault() && m_accountToUse) { - accounts->setDefaultAccount(m_accountToUse); + // If the user said to use the account as default, do that. + if (selectDialog.useAsGlobalDefault() && m_accountToUse) { + accounts->setDefaultAccount(m_accountToUse); + } } } } @@ -269,18 +296,17 @@ void LaunchController::login() if (!m_session->wants_online) { if (m_accountToUse->isMSA()) { // MSA account in offline mode: ask for a player name - bool ok = false; - QString usedname = m_session->player_name; - QString name = QInputDialog::getText( - m_parentWidget, tr("Player name"), + auto name = LAUNCHER->uiHost()->askText( + tr("Player name"), tr("Choose your offline mode player name."), - QLineEdit::Normal, m_session->player_name, &ok); - if (!ok) { + m_session->player_name); + if (!name) { tryagain = false; break; } - if (name.length()) { - usedname = name; + QString usedname = m_session->player_name; + if (name->length()) { + usedname = *name; } m_session->MakeOffline(usedname); } else { @@ -293,6 +319,25 @@ void LaunchController::login() if (m_accountToUse->ownsMinecraft()) { if (!m_accountToUse->hasProfile()) { // Now handle setting up a profile name here... + if (APPLICATION->usingQmlShell()) { + /* ProfileSetupDialog(m_accountToUse, + * m_parentWidget) below would build with a null + * parent under QML (m_parentWidget is unset on + * this path) -- the same defect class as + * createInstanceRequested's SIGSEGV. Ask through + * UiHost instead, which shows a QML dialog doing + * the same live name check and profile + * creation. */ + if (LAUNCHER->uiHost()->setupProfile( + m_accountToUse)) { + tryagain = true; + continue; + } else { + emitFailed(tr("Received undetermined session " + "status during login.")); + return; + } + } ProfileSetupDialog dialog(m_accountToUse, m_parentWidget); if (dialog.exec() == QDialog::Accepted) { @@ -310,20 +355,14 @@ void LaunchController::login() return; } else { // play demo ? - QMessageBox box(m_parentWidget); - box.setWindowTitle(tr("Play demo?")); - box.setText(tr("This account does not own Minecraft.\nYou " - "need to purchase the game first to play " - "it.\n\nDo you want to play the demo?")); - box.setIcon(QMessageBox::Warning); - auto demoButton = box.addButton( - tr("Play Demo"), QMessageBox::ButtonRole::YesRole); - auto cancelButton = box.addButton( - tr("Cancel"), QMessageBox::ButtonRole::NoRole); - box.setDefaultButton(cancelButton); - - box.exec(); - if (box.clickedButton() == demoButton) { + const bool playDemo = LAUNCHER->uiHost()->confirm( + tr("Play demo?"), + tr("This account does not own Minecraft.\nYou " + "need to purchase the game first to play " + "it.\n\nDo you want to play the demo?"), + UiHost::Severity::Warning, tr("Play Demo"), + tr("Cancel")); + if (playDemo) { // play demo here m_session->MakeDemo(); launchInstance(); @@ -344,12 +383,31 @@ void LaunchController::login() case AccountState::Working: { // refresh is in progress, we need to wait for it to finish to // proceed. - ProgressDialog progDialog(m_parentWidget); - if (m_online) { - progDialog.setSkipButton(true, tr("Play Offline")); - } auto task = m_accountToUse->currentTask(); - progDialog.execWithTask(task.get()); + if (APPLICATION->usingQmlShell()) { + /* No card to reflect this on -- there is no launch task + * yet, just an account refresh -- and no "Play Offline" + * skip affordance without a dedicated UiHost API for + * it, so this is a plain wait with a busy indication. */ + if (!task->isFinished()) { + auto busy = LAUNCHER->uiHost()->showBusy( + tr("Refreshing account…")); + if (!task->isRunning()) { + QMetaObject::invokeMethod(task.get(), &Task::start, + Qt::QueuedConnection); + } + QEventLoop loop; + connect(task.get(), &Task::finished, &loop, + &QEventLoop::quit); + loop.exec(); + } + } else { + ProgressDialog progDialog(m_parentWidget); + if (m_online) { + progDialog.setSkipButton(true, tr("Play Offline")); + } + progDialog.execWithTask(task.get()); + } continue; } // FIXME: this is missing - the meaning is that the account is @@ -362,10 +420,9 @@ void LaunchController::login() case AccountState::Expired: { auto errorString = tr("The account has expired and needs to be " "logged into manually again."); - QMessageBox::warning(m_parentWidget, - tr("Account refresh failed"), errorString, - QMessageBox::StandardButton::Ok, - QMessageBox::StandardButton::Ok); + LAUNCHER->uiHost()->message(tr("Account refresh failed"), + errorString, + UiHost::Severity::Warning); emitFailed(errorString); return; } @@ -374,10 +431,8 @@ void LaunchController::login() tr("The account no longer exists on the servers. It may " "have been migrated, in which case please add the new " "account you migrated this one to."); - QMessageBox::warning(m_parentWidget, tr("Account gone"), - errorString, - QMessageBox::StandardButton::Ok, - QMessageBox::StandardButton::Ok); + LAUNCHER->uiHost()->message(tr("Account gone"), errorString, + UiHost::Severity::Warning); emitFailed(errorString); return; } @@ -392,8 +447,9 @@ void LaunchController::launchInstance() Q_ASSERT_X(m_session.get() != nullptr, "launchInstance", "session is NULL"); if (!m_instance->reloadSettings()) { - QMessageBox::critical(m_parentWidget, tr("Error!"), - tr("Couldn't load the instance profile.")); + LAUNCHER->uiHost()->message(tr("Error!"), + tr("Couldn't load the instance profile."), + UiHost::Severity::Critical); emitFailed(tr("Couldn't load the instance profile.")); return; } @@ -407,7 +463,7 @@ void LaunchController::launchInstance() auto console = qobject_cast(m_parentWidget); auto showConsole = m_instance->settings()->get("ShowConsole").toBool(); if (!console && showConsole) { - APPLICATION->showInstanceWindow(m_instance); + APPLICATION->showInstanceLog(m_instance); } connect(m_launcher.get(), &LaunchTask::readyForLaunch, this, &LaunchController::readyForLaunch); @@ -538,8 +594,9 @@ void LaunchController::readyForLaunch() QString error; if (!m_profiler->check(&error)) { m_launcher->abort(); - QMessageBox::critical(m_parentWidget, tr("Error!"), - tr("Couldn't start profiler: %1").arg(error)); + LAUNCHER->uiHost()->message(tr("Error!"), + tr("Couldn't start profiler: %1").arg(error), + UiHost::Severity::Critical); emitFailed("Profiler startup failed!"); return; } @@ -548,28 +605,22 @@ void LaunchController::readyForLaunch() connect(profilerInstance, &BaseProfiler::readyToLaunch, [this](const QString& message) { - QMessageBox msg; - msg.setText(tr("The game launch is delayed until you press the " - "button. This is the right time to setup the " - "profiler, as the " - "profiler server is running now.\n\n%1") - .arg(message)); - msg.setWindowTitle(tr("Waiting.")); - msg.setIcon(QMessageBox::Information); - msg.addButton(tr("Launch"), QMessageBox::AcceptRole); - msg.setModal(true); - msg.exec(); + LAUNCHER->uiHost()->message( + tr("Waiting."), + tr("The game launch is delayed until you press the " + "button. This is the right time to setup the " + "profiler, as the " + "profiler server is running now.\n\n%1") + .arg(message), + UiHost::Severity::Information); m_launcher->proceed(); }); connect(profilerInstance, &BaseProfiler::abortLaunch, [this](const QString& message) { - QMessageBox msg; - msg.setText(tr("Couldn't start the profiler: %1").arg(message)); - msg.setWindowTitle(tr("Error")); - msg.setIcon(QMessageBox::Critical); - msg.addButton(QMessageBox::Ok); - msg.setModal(true); - msg.exec(); + LAUNCHER->uiHost()->message( + tr("Error"), + tr("Couldn't start the profiler: %1").arg(message), + UiHost::Severity::Critical); m_launcher->abort(); emitFailed("Profiler startup failed!"); }); @@ -598,13 +649,22 @@ void LaunchController::onSucceeded() void LaunchController::onFailed(QString reason) { if (m_instance->settings()->get("ShowConsoleOnError").toBool()) { - APPLICATION->showInstanceWindow(m_instance, "console"); + APPLICATION->showInstanceLog(m_instance); } emitFailed(reason); } void LaunchController::onProgressRequested(Task* task) { + if (APPLICATION->usingQmlShell()) { + /* The instance's card already tracks this task's status/progress + * (see InstanceList::trackLaunchProgress()) -- proceeding is all + * this step is actually waiting on; no dialog needed to make it + * visible, and no "Abort" affordance without a dedicated UiHost + * API for it. */ + m_launcher->proceed(); + return; + } ProgressDialog progDialog(m_parentWidget); progDialog.setSkipButton(true, tr("Abort")); m_launcher->proceed(); @@ -622,32 +682,32 @@ bool LaunchController::abort() // explanation. Say it here, where the reason is actually known, // instead of threading a result code through Application::kill(). if (m_launcher->isAborting()) { - CustomMessageBox::selectable( - m_parentWidget, tr("Already stopping"), + LAUNCHER->uiHost()->message( + tr("Already stopping"), tr("MeshMC is already shutting this instance down. Give it a " "few seconds - if the game does not react, it gets killed " "automatically."), - QMessageBox::Information) - ->exec(); + UiHost::Severity::Information); } else { - CustomMessageBox::selectable( - m_parentWidget, tr("Can't kill Minecraft"), + LAUNCHER->uiHost()->message( + tr("Can't kill Minecraft"), tr("This instance is at a point in the launch process that " "can't be interrupted. Please try again in a moment."), - QMessageBox::Warning) - ->exec(); + UiHost::Severity::Warning); } return false; } - auto response = CustomMessageBox::selectable( - m_parentWidget, tr("Kill Minecraft?"), - tr("This can cause the instance to get corrupted and " - "should only be used if Minecraft " - "is frozen for some reason"), - QMessageBox::Question, - QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes) - ->exec(); - if (response == QMessageBox::Yes) { + /* Widget note: CustomMessageBox::selectable() used to default this + * particular confirmation to Yes -- confirm() always defaults to the + * declining answer instead (see WidgetUiHost::confirm()), so pressing + * Enter here now cancels rather than kills the instance. */ + const bool confirmed = LAUNCHER->uiHost()->confirm( + tr("Kill Minecraft?"), + tr("This can cause the instance to get corrupted and " + "should only be used if Minecraft " + "is frozen for some reason"), + UiHost::Severity::Question); + if (confirmed) { return m_launcher->abort(); } return false; diff --git a/launcher/LaunchController.h b/launcher/ui/LaunchController.h similarity index 100% rename from launcher/LaunchController.h rename to launcher/ui/LaunchController.h diff --git a/launcher/ui/MainWindow.cpp b/launcher/ui/MainWindow.cpp index e72538b7..dd71174d 100644 --- a/launcher/ui/MainWindow.cpp +++ b/launcher/ui/MainWindow.cpp @@ -80,9 +80,9 @@ #include #include #include "InstanceWindow.h" -#include "InstancePageProvider.h" -#include "JavaCommon.h" -#include "LaunchController.h" +#include "ui/InstancePageProvider.h" +#include "ui/JavaCommon.h" +#include "ui/LaunchController.h" #include "ui/instanceview/InstanceProxyModel.h" #include "ui/instanceview/InstanceView.h" @@ -1265,13 +1265,6 @@ void MainWindow::runModalTask(Task* task) loadDialog.execWithTask(task); } -void MainWindow::instanceFromInstanceTask(InstanceTask* rawTask) -{ - unique_qobject_ptr task( - APPLICATION->instances()->wrapInstanceTask(rawTask)); - runModalTask(task.get()); -} - void MainWindow::on_actionCopyInstance_triggered() { if (!m_selectedInstance) @@ -1343,7 +1336,14 @@ void MainWindow::addInstance(QString url) .toString(); } - NewInstanceDialog newInstDlg(groupName, url, this); + createInstanceFromDialog(this, groupName, url); +} + +void MainWindow::createInstanceFromDialog(QWidget* parent, + const QString& groupName, + const QString& url) +{ + NewInstanceDialog newInstDlg(groupName, url, parent); if (!newInstDlg.exec()) return; @@ -1357,9 +1357,29 @@ void MainWindow::addInstance(QString url) newInstDlg.instDir()); InstanceTask* creationTask = newInstDlg.extractTask(); - if (creationTask) { - instanceFromInstanceTask(creationTask); + if (!creationTask) { + return; } + + unique_qobject_ptr task( + APPLICATION->instances()->wrapInstanceTask(creationTask)); + connect(task.get(), &Task::failed, [parent](QString reason) { + CustomMessageBox::selectable(parent, tr("Error"), reason, + QMessageBox::Critical) + ->show(); + }); + connect(task.get(), &Task::succeeded, [parent, rawTask = task.get()]() { + QStringList warnings = rawTask->warnings(); + if (warnings.count()) { + CustomMessageBox::selectable(parent, tr("Warnings"), + warnings.join('\n'), + QMessageBox::Warning) + ->show(); + } + }); + ProgressDialog loadDialog(parent); + loadDialog.setSkipButton(true, tr("Abort")); + loadDialog.execWithTask(task.get()); } void MainWindow::on_actionAddInstance_triggered() diff --git a/launcher/ui/MainWindow.h b/launcher/ui/MainWindow.h index 7747e9d6..3feff694 100644 --- a/launcher/ui/MainWindow.h +++ b/launcher/ui/MainWindow.h @@ -80,6 +80,16 @@ class MainWindow : public QMainWindow void droppedURLs(QList urls); + /** + * Runs the "create a new instance" dialog and, if accepted, the task + * that installs it - the same flow addInstance() runs for the widget + * menu, extracted so the QML shell can start it without a MainWindow + * to call it on. @p parent may be nullptr. + */ + static void createInstanceFromDialog(QWidget* parent, + const QString& groupName, + const QString& url = QString()); + NewsChecker* newsChecker() const { return m_newsChecker.get(); @@ -295,7 +305,6 @@ class MainWindow : public QMainWindow void updateStatusCenter(); void runModalTask(Task* task); - void instanceFromInstanceTask(InstanceTask* task); void finalizeInstance(InstancePtr inst); /* Opens (or raises) the news dialog. withSidebar picks between diff --git a/launcher/minecraft/ShortcutUtils.cpp b/launcher/ui/ShortcutUtils.cpp similarity index 99% rename from launcher/minecraft/ShortcutUtils.cpp rename to launcher/ui/ShortcutUtils.cpp index fee9470d..b0f59bbb 100644 --- a/launcher/minecraft/ShortcutUtils.cpp +++ b/launcher/ui/ShortcutUtils.cpp @@ -17,7 +17,7 @@ * limitations under the License. */ -#include "ShortcutUtils.h" +#include "ui/ShortcutUtils.h" #include #include diff --git a/launcher/minecraft/ShortcutUtils.h b/launcher/ui/ShortcutUtils.h similarity index 100% rename from launcher/minecraft/ShortcutUtils.h rename to launcher/ui/ShortcutUtils.h diff --git a/launcher/ui/WidgetUiHost.cpp b/launcher/ui/WidgetUiHost.cpp new file mode 100644 index 00000000..32a93b0b --- /dev/null +++ b/launcher/ui/WidgetUiHost.cpp @@ -0,0 +1,208 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "ui/WidgetUiHost.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "ui/dialogs/BlockedModsDialog.h" +#include "ui/dialogs/CustomMessageBox.h" +#include "ui/dialogs/ProfileSetupDialog.h" +#include "ui/dialogs/UntrustedModsDialog.h" +#include "ui/dialogs/UpdateAvailableDialog.h" + +namespace +{ + QMessageBox::Icon toIcon(UiHost::Severity severity) + { + switch (severity) { + case UiHost::Severity::Information: + return QMessageBox::Information; + case UiHost::Severity::Question: + return QMessageBox::Question; + case UiHost::Severity::Warning: + return QMessageBox::Warning; + case UiHost::Severity::Critical: + return QMessageBox::Critical; + } + return QMessageBox::NoIcon; + } + + QWidget* activeWindow() + { + return QApplication::activeWindow(); + } +} // namespace + +namespace +{ + /* Indeterminate and uncancellable: the caller has nothing to report and + * nothing it could abort. */ + class ProgressDialogBusy final : public UiHost::BusyIndicator + { + public: + explicit ProgressDialogBusy(const QString& text) + : m_dialog(text, QString(), 0, 0, QApplication::activeWindow()) + { + m_dialog.setWindowTitle(text); + m_dialog.setMinimumDuration(0); + m_dialog.setCancelButton(nullptr); + m_dialog.adjustSize(); + m_dialog.show(); + } + + private: + QProgressDialog m_dialog; + }; +} // namespace + +std::unique_ptr +WidgetUiHost::showBusy(const QString& text) +{ + return std::make_unique(text); +} + +void WidgetUiHost::message(const QString& title, const QString& text, + Severity severity) +{ + CustomMessageBox::selectable(activeWindow(), title, text, + toIcon(severity), QMessageBox::Ok, + QMessageBox::Ok) + ->exec(); +} + +bool WidgetUiHost::confirm(const QString& title, const QString& text, + Severity severity, const QString& acceptLabel, + const QString& rejectLabel) +{ + auto* box = CustomMessageBox::selectable( + activeWindow(), title, text, toIcon(severity), + QMessageBox::Yes | QMessageBox::No, QMessageBox::No); + + if (!acceptLabel.isEmpty()) { + if (auto* accept = box->button(QMessageBox::Yes)) { + accept->setText(acceptLabel); + } + } + if (!rejectLabel.isEmpty()) { + if (auto* reject = box->button(QMessageBox::No)) { + reject->setText(rejectLabel); + } + } + + return box->exec() == QMessageBox::Yes; +} + +int WidgetUiHost::choose(const QString& title, const QString& text, + Severity severity, const QStringList& actions) +{ + auto* box = + CustomMessageBox::selectable(activeWindow(), title, text, + toIcon(severity), QMessageBox::Cancel, + QMessageBox::Cancel); + + /* AcceptRole for every action: the roles would otherwise decide the + * button order for us, and the caller's order is the meaningful one. */ + QVector buttons; + buttons.reserve(actions.size()); + for (const QString& action : actions) { + buttons.append(box->addButton(action, QMessageBox::AcceptRole)); + } + + box->exec(); + + const int index = buttons.indexOf(box->clickedButton()); + return index; /* -1 when Cancel or the window's close button was used */ +} + +std::optional WidgetUiHost::askText(const QString& title, + const QString& text, + const QString& defaultValue) +{ + bool ok = false; + QString result = QInputDialog::getText(activeWindow(), title, text, + QLineEdit::Normal, defaultValue, + &ok); + if (!ok) { + return std::nullopt; + } + return result; +} + +bool WidgetUiHost::resolveBlockedMods(const QString& title, + const QString& text, + QList& mods) +{ + BlockedModsDialog dialog(activeWindow(), title, text, mods); + return dialog.exec() == QDialog::Accepted; +} + +bool WidgetUiHost::confirmUntrustedMods(const QStringList& suspectPaths) +{ + UntrustedModsDialog dialog(suspectPaths, activeWindow()); + return dialog.exec() == QDialog::Accepted; +} + +UiHost::UpdateChoice WidgetUiHost::offerUpdate(const QString& currentVersion, + const QString& availableVersion, + const QString& releaseNotes) +{ + UpdateAvailableDialog dialog(currentVersion, availableVersion, + releaseNotes, activeWindow()); + switch (dialog.exec()) { + case UpdateAvailableDialog::Install: + return UpdateChoice::Install; + case UpdateAvailableDialog::Skip: + return UpdateChoice::Skip; + default: + /* DontInstall, or the window was simply closed -- both leave + * the offer standing for next time. */ + return UpdateChoice::Later; + } +} + +bool WidgetUiHost::setupProfile(MinecraftAccountPtr account) +{ + ProfileSetupDialog dialog(account, activeWindow()); + return dialog.exec() == QDialog::Accepted; +} + +std::optional WidgetUiHost::pickFile(FilePickerMode mode, + const QString& title, + const QString& defaultPath, + const QString& filter) +{ + const QString result = + mode == FilePickerMode::Open + ? QFileDialog::getOpenFileName(activeWindow(), title, QString(), + filter) + : QFileDialog::getSaveFileName(activeWindow(), title, defaultPath, + filter); + if (result.isEmpty()) { + return std::nullopt; + } + return result; +} diff --git a/launcher/ui/WidgetUiHost.h b/launcher/ui/WidgetUiHost.h new file mode 100644 index 00000000..ee44c6be --- /dev/null +++ b/launcher/ui/WidgetUiHost.h @@ -0,0 +1,65 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "core/UiHost.h" + +/* + * Answers the core's questions with QtWidgets dialogs. + * + * Parented to whatever window is active at the moment the question is asked + * rather than to a widget handed over in advance: the tasks that ask these + * questions outlive any particular window, and the old code threaded a + * QWidget* through the task API purely to have something to parent to. + */ +class WidgetUiHost final : public UiHost +{ + public: + std::unique_ptr showBusy(const QString& text) override; + + void message(const QString& title, const QString& text, + Severity severity) override; + + bool confirm(const QString& title, const QString& text, + Severity severity, const QString& acceptLabel = QString(), + const QString& rejectLabel = QString()) override; + + int choose(const QString& title, const QString& text, Severity severity, + const QStringList& actions) override; + + std::optional askText( + const QString& title, const QString& text, + const QString& defaultValue = QString()) override; + + bool resolveBlockedMods(const QString& title, const QString& text, + QList& mods) override; + + bool confirmUntrustedMods(const QStringList& suspectPaths) override; + + UpdateChoice offerUpdate(const QString& currentVersion, + const QString& availableVersion, + const QString& releaseNotes) override; + + bool setupProfile(MinecraftAccountPtr account) override; + + std::optional pickFile(FilePickerMode mode, const QString& title, + const QString& defaultPath, + const QString& filter) override; +}; diff --git a/launcher/ui/dialogs/BlockedModsDialog.h b/launcher/ui/dialogs/BlockedModsDialog.h index 3346ddee..ff33ff95 100644 --- a/launcher/ui/dialogs/BlockedModsDialog.h +++ b/launcher/ui/dialogs/BlockedModsDialog.h @@ -25,14 +25,7 @@ #include #include #include - -struct BlockedMod { - int projectId; - int fileId; - QString fileName; - QString targetPath; - bool found = false; -}; +#include "modplatform/BlockedMod.h" class BlockedModsDialog : public QDialog { diff --git a/launcher/ui/dialogs/CreateShortcutDialog.cpp b/launcher/ui/dialogs/CreateShortcutDialog.cpp index aa82133b..8dad669a 100644 --- a/launcher/ui/dialogs/CreateShortcutDialog.cpp +++ b/launcher/ui/dialogs/CreateShortcutDialog.cpp @@ -27,7 +27,7 @@ #include "FileSystem.h" #include "icons/IconList.h" #include "minecraft/MinecraftInstance.h" -#include "minecraft/ShortcutUtils.h" +#include "ui/ShortcutUtils.h" #include "minecraft/World.h" #include "minecraft/WorldList.h" #include "minecraft/auth/AccountList.h" diff --git a/launcher/ui/dialogs/ExportInstanceDialog.h b/launcher/ui/dialogs/ExportInstanceDialog.h index 2cba7295..728250ae 100644 --- a/launcher/ui/dialogs/ExportInstanceDialog.h +++ b/launcher/ui/dialogs/ExportInstanceDialog.h @@ -24,7 +24,7 @@ #include #include -#include "FastFileIconProvider.h" +#include "ui/FastFileIconProvider.h" class BaseInstance; class FileIgnoreProxy; diff --git a/launcher/ui/dialogs/ExportPackDialog.h b/launcher/ui/dialogs/ExportPackDialog.h index 399c7ac8..01f472e9 100644 --- a/launcher/ui/dialogs/ExportPackDialog.h +++ b/launcher/ui/dialogs/ExportPackDialog.h @@ -24,7 +24,7 @@ #include -#include "FastFileIconProvider.h" +#include "ui/FastFileIconProvider.h" #include "tasks/Task.h" class FileIgnoreProxy; diff --git a/launcher/ui/dialogs/PluginsDialog.cpp b/launcher/ui/dialogs/PluginsDialog.cpp index 82eabb3d..96eb85e9 100644 --- a/launcher/ui/dialogs/PluginsDialog.cpp +++ b/launcher/ui/dialogs/PluginsDialog.cpp @@ -55,6 +55,10 @@ namespace return QObject::tr("Dependency cycle"); case PluginDisableReason::SupersededByCore: return QObject::tr("Built into MeshMC"); + case PluginDisableReason::AbiTooOld: + return QObject::tr("Plugin ABI too old"); + case PluginDisableReason::AbiTooNew: + return QObject::tr("Plugin ABI too new"); } return QString(); } diff --git a/launcher/ui/pages/global/JavaPage.cpp b/launcher/ui/pages/global/JavaPage.cpp index a0e42871..f56fc68e 100644 --- a/launcher/ui/pages/global/JavaPage.cpp +++ b/launcher/ui/pages/global/JavaPage.cpp @@ -19,7 +19,7 @@ */ #include "JavaPage.h" -#include "JavaCommon.h" +#include "ui/JavaCommon.h" #include "ui_JavaPage.h" #include diff --git a/launcher/ui/pages/global/JavaPage.h b/launcher/ui/pages/global/JavaPage.h index 80104e1c..44d7b353 100644 --- a/launcher/ui/pages/global/JavaPage.h +++ b/launcher/ui/pages/global/JavaPage.h @@ -23,7 +23,7 @@ #include #include #include "ui/pages/BasePage.h" -#include "JavaCommon.h" +#include "ui/JavaCommon.h" #include #include diff --git a/launcher/ui/pages/instance/InstanceSettingsPage.cpp b/launcher/ui/pages/instance/InstanceSettingsPage.cpp index 8ccd5c52..91f76f3a 100644 --- a/launcher/ui/pages/instance/InstanceSettingsPage.cpp +++ b/launcher/ui/pages/instance/InstanceSettingsPage.cpp @@ -29,7 +29,7 @@ #include "ui/dialogs/VersionSelectDialog.h" #include "ui/widgets/CustomCommands.h" -#include "JavaCommon.h" +#include "ui/JavaCommon.h" #include "Application.h" #include "java/JavaInstallList.h" diff --git a/launcher/ui/pages/instance/InstanceSettingsPage.h b/launcher/ui/pages/instance/InstanceSettingsPage.h index 78f89373..bb73ee49 100644 --- a/launcher/ui/pages/instance/InstanceSettingsPage.h +++ b/launcher/ui/pages/instance/InstanceSettingsPage.h @@ -26,7 +26,7 @@ #include "BaseInstance.h" #include #include "ui/pages/BasePage.h" -#include "JavaCommon.h" +#include "ui/JavaCommon.h" #include "Application.h" class JavaChecker; diff --git a/launcher/ui/pages/instance/ManagedPackPage.cpp b/launcher/ui/pages/instance/ManagedPackPage.cpp index 1b067118..adfc38c8 100644 --- a/launcher/ui/pages/instance/ManagedPackPage.cpp +++ b/launcher/ui/pages/instance/ManagedPackPage.cpp @@ -792,7 +792,6 @@ void ManagedPackPage::updatePack(const QUrl& url, bool trusted, target.versionId = versionId; target.versionLabel = versionName; task->setUpdateTarget(target); - task->setDialogParent(this); task->setTrustedSource(trusted); /* Carry the pack's identity across explicitly. diff --git a/launcher/ui/pages/modplatform/flame/FlamePage.cpp b/launcher/ui/pages/modplatform/flame/FlamePage.cpp index 4691a345..fb35fa03 100644 --- a/launcher/ui/pages/modplatform/flame/FlamePage.cpp +++ b/launcher/ui/pages/modplatform/flame/FlamePage.cpp @@ -236,7 +236,6 @@ void FlamePage::suggestCurrent() * to update an instance this pack is already installed in, most * likely - and a question about what the user is doing in this window * belongs to this window. */ - task->setDialogParent(this); task->setPackSourceHint(hint); dialog->setSuggestedPack(current.name, task); diff --git a/launcher/ui/pages/modplatform/modrinth/ModrinthPage.cpp b/launcher/ui/pages/modplatform/modrinth/ModrinthPage.cpp index fb8ac69c..b363b739 100644 --- a/launcher/ui/pages/modplatform/modrinth/ModrinthPage.cpp +++ b/launcher/ui/pages/modplatform/modrinth/ModrinthPage.cpp @@ -243,7 +243,6 @@ void ModrinthPage::suggestCurrent() task->setTrustedSource(true); /* Same as the CurseForge page: any question the task raises is a * question about what the user is doing here. */ - task->setDialogParent(this); task->setPackSourceHint(hint); dialog->setSuggestedPack(current.name, task); QString editedLogoName; diff --git a/launcher/ui/setupwizard/JavaWizardPage.cpp b/launcher/ui/setupwizard/JavaWizardPage.cpp index 65be8211..569ae50c 100644 --- a/launcher/ui/setupwizard/JavaWizardPage.cpp +++ b/launcher/ui/setupwizard/JavaWizardPage.cpp @@ -35,7 +35,7 @@ #include "FileSystem.h" #include "java/JavaInstall.h" #include "java/JavaUtils.h" -#include "JavaCommon.h" +#include "ui/JavaCommon.h" #include "java/download/JavaRuntime.h" #include "ui/widgets/VersionSelectWidget.h" diff --git a/launcher/ui/widgets/ProjectItemDelegate.h b/launcher/ui/widgets/ProjectItemDelegate.h index 924f00de..7a8cc914 100644 --- a/launcher/ui/widgets/ProjectItemDelegate.h +++ b/launcher/ui/widgets/ProjectItemDelegate.h @@ -22,27 +22,10 @@ #include #include -/* Extra data roles the project list models expose for the delegate below. - * - * Qt::UserRole itself is already taken: both search models return the - * platform's project id there, and existing code reads it. These start - * one past it. - * - * Qt::DisplayRole is deliberately not reused for the title. The default - * delegate paints DisplayRole, so leaving it as the plain name keeps the - * list readable if a view is ever shown without this delegate attached. */ -namespace ProjectItemRole -{ - enum Role { - /* QString - project name, drawn large on the first line. */ - Title = Qt::UserRole + 1, - /* QString - short summary, wrapped over at most two lines. */ - Description, - /* bool - already present in the target folder. Such rows are - * dimmed and tagged, because installing them again is a no-op. */ - Installed, - }; -} +/* For ProjectItemRole: the data roles this delegate reads off the model's + * data() when painting a row. Declared with the model rather than here, + * since the model is what produces them. */ +#include "modplatform/ContentProviderModel.h" /* Draws one search result: optional checkbox, icon, title, description. * diff --git a/launcher/updater/MeshMCExternalUpdater.cpp b/launcher/updater/MeshMCExternalUpdater.cpp index aafee074..63ed3171 100644 --- a/launcher/updater/MeshMCExternalUpdater.cpp +++ b/launcher/updater/MeshMCExternalUpdater.cpp @@ -21,17 +21,16 @@ #include #include -#include #include #include -#include #include #include #include #include "BuildConfig.h" -#include "ui/dialogs/UpdateAvailableDialog.h" +#include "core/LauncherContext.h" +#include "core/UiHost.h" namespace { @@ -65,20 +64,16 @@ namespace }; /*! - * All of the updater's message boxes look the same: wide enough that a - * path or a version string does not wrap into nonsense, and with the raw - * child output tucked behind "Show Details" when there is any. + * Folds raw child output into the message body. + * + * UiHost::message() has no "Show Details" affordance to tuck this + * behind, so it goes in the body instead when there is any. */ - void showMessage(QWidget* parent, QMessageBox::Icon icon, - const QString& title, const QString& text, - const QString& details = QString()) + QString withDetails(const QString& text, const QString& details = QString()) { - QMessageBox box(icon, title, text, QMessageBox::Ok, parent); - if (!details.isEmpty()) - box.setDetailedText(details); - box.setMinimumWidth(460); - box.adjustSize(); - box.exec(); + if (details.isEmpty()) + return text; + return text + QLatin1String("\n\n") + details; } /*! @@ -124,11 +119,10 @@ QString MeshMCExternalUpdater::updaterBinaryRelativePath() #endif } -MeshMCExternalUpdater::MeshMCExternalUpdater(QWidget* parent, - const QString& appDir, +MeshMCExternalUpdater::MeshMCExternalUpdater(const QString& appDir, const QString& dataDir, bool autoCheckDefault) - : m_appDir(appDir), m_dataDir(dataDir), m_parent(parent) + : m_appDir(appDir), m_dataDir(dataDir) { m_settings = std::make_unique( m_dataDir.absoluteFilePath(QLatin1String(kConfigFileName)), @@ -217,17 +211,12 @@ void MeshMCExternalUpdater::checkForUpdates(bool triggeredByUser) m_checking = true; emit canCheckForUpdatesChanged(false); - // The check blocks, so the progress dialog exists to prove the launcher + // The check blocks, so the busy indication exists to prove the launcher // has not simply frozen. An automatic check gets none: nobody asked, and // a window stealing focus during startup is worse than no feedback. - QProgressDialog progress(tr("Checking for updates..."), QString(), 0, 0, - m_parent); - progress.setWindowTitle(tr("Checking for updates...")); - progress.setMinimumDuration(0); - progress.setCancelButton(nullptr); - progress.adjustSize(); + std::unique_ptr busy; if (triggeredByUser) - progress.show(); + busy = LAUNCHER->uiHost()->showBusy(tr("Checking for updates...")); QCoreApplication::processEvents(); QProcess proc; @@ -252,11 +241,12 @@ void MeshMCExternalUpdater::checkForUpdates(bool triggeredByUser) qWarning() << "Updater: the check did not start within" << kStartTimeoutMs / 1000 << "seconds:" << proc.error() << proc.errorString(); - progress.cancel(); - showMessage(m_parent, QMessageBox::Information, - tr("Update Check Failed"), - tr("Failed to start after 5 seconds\nReason: %1.") - .arg(proc.errorString())); + busy.reset(); + LAUNCHER->uiHost()->message( + tr("Update Check Failed"), + tr("Failed to start after 5 seconds\nReason: %1.") + .arg(proc.errorString()), + UiHost::Severity::Information); noteCheckCompleted(); return; } @@ -269,12 +259,13 @@ void MeshMCExternalUpdater::checkForUpdates(bool triggeredByUser) qWarning() << "Updater: the check did not finish within" << kFinishTimeoutMs / 1000 << "seconds:" << proc.error() << proc.errorString(); - progress.cancel(); - showMessage(m_parent, QMessageBox::Information, - tr("Update Check Failed"), - tr("Updater failed to close 60 seconds\nReason: %1.") - .arg(proc.errorString()), - QString::fromUtf8(output)); + busy.reset(); + LAUNCHER->uiHost()->message( + tr("Update Check Failed"), + withDetails(tr("Updater failed to close 60 seconds\nReason: %1.") + .arg(proc.errorString()), + QString::fromUtf8(output)), + UiHost::Severity::Information); noteCheckCompleted(); return; } @@ -283,26 +274,28 @@ void MeshMCExternalUpdater::checkForUpdates(bool triggeredByUser) const QByteArray stdOutput = proc.readAllStandardOutput(); const QByteArray stdError = proc.readAllStandardError(); - progress.cancel(); + busy.reset(); QCoreApplication::processEvents(); switch (exitCode) { case CheckExitCode::NoUpdate: qDebug() << "Updater: no update available."; if (triggeredByUser) { - showMessage(m_parent, QMessageBox::Information, - tr("No Update Available"), - tr("You are running the latest version.")); + LAUNCHER->uiHost()->message(tr("No Update Available"), + tr("You are running the latest " + "version."), + UiHost::Severity::Information); } break; case CheckExitCode::CheckError: qWarning() << "Updater: the check reported an error:" << qPrintable(QString::fromUtf8(stdError)); - showMessage(m_parent, QMessageBox::Warning, - tr("Update Check Error"), - tr("There was an error running the update check."), - QString::fromUtf8(stdError)); + LAUNCHER->uiHost()->message( + tr("Update Check Error"), + withDetails(tr("There was an error running the update check."), + QString::fromUtf8(stdError)), + UiHost::Severity::Warning); break; case CheckExitCode::UpdateAvailable: { @@ -326,12 +319,14 @@ void MeshMCExternalUpdater::checkForUpdates(bool triggeredByUser) // remember as skipped, so this is an error, not an offer. qWarning() << "Updater: the check reported an update but no " "version tag."; - showMessage(m_parent, QMessageBox::Warning, - tr("Update Check Error"), - tr("There was an error running the update check."), - tr("StdOut: %1\nStdErr: %2") - .arg(QString::fromUtf8(stdOutput), - QString::fromUtf8(stdError))); + LAUNCHER->uiHost()->message( + tr("Update Check Error"), + withDetails( + tr("There was an error running the update check."), + tr("StdOut: %1\nStdErr: %2") + .arg(QString::fromUtf8(stdOutput), + QString::fromUtf8(stdError))), + UiHost::Severity::Warning); break; } @@ -343,14 +338,16 @@ void MeshMCExternalUpdater::checkForUpdates(bool triggeredByUser) default: qWarning() << "Updater: the check exited with an unknown code" << exitCode; - showMessage( - m_parent, QMessageBox::Information, tr("Unknown Update Error"), - tr("The updater exited with an unknown condition.\nExit Code: " - "%1") - .arg(QString::number(exitCode)), - tr("StdOut: %1\nStdErr: %2") - .arg(QString::fromUtf8(stdOutput), - QString::fromUtf8(stdError))); + LAUNCHER->uiHost()->message( + tr("Unknown Update Error"), + withDetails( + tr("The updater exited with an unknown condition.\nExit " + "Code: %1") + .arg(QString::number(exitCode)), + tr("StdOut: %1\nStdErr: %2") + .arg(QString::fromUtf8(stdOutput), + QString::fromUtf8(stdError))), + UiHost::Severity::Information); } noteCheckCompleted(); @@ -385,18 +382,17 @@ void MeshMCExternalUpdater::offerUpdate(const QString& versionName, return; } - UpdateAvailableDialog dialog(BuildConfig.printableVersionString(), - versionName, releaseNotes, m_parent); - const int result = dialog.exec(); + const UiHost::UpdateChoice choice = LAUNCHER->uiHost()->offerUpdate( + BuildConfig.printableVersionString(), versionName, releaseNotes); m_settings->beginGroup(kGroupSkip); - switch (result) { - case UpdateAvailableDialog::Skip: + switch (choice) { + case UiHost::UpdateChoice::Skip: qDebug() << "Updater: remembering" << versionTag << "as skipped."; m_settings->setValue(versionTag, true); break; - case UpdateAvailableDialog::Install: + case UiHost::UpdateChoice::Install: // Forget any earlier skip: the user just chose to install this // very version. m_settings->remove(versionTag); @@ -405,7 +401,7 @@ void MeshMCExternalUpdater::offerUpdate(const QString& versionName, performUpdate(versionTag); return; - default: + case UiHost::UpdateChoice::Later: // "Remind Me Later", or the window was simply closed. qDebug() << "Updater: leaving" << versionTag << "for later."; m_settings->remove(versionTag); @@ -437,9 +433,11 @@ void MeshMCExternalUpdater::performUpdate(const QString& versionTag) if (!proc.startDetached()) { qCritical() << "Updater: failed to start the updater:" << proc.error() << proc.errorString(); - showMessage(m_parent, QMessageBox::Warning, tr("Update Failed"), - tr("Could not start the updater.\nReason: %1.") - .arg(proc.errorString())); + LAUNCHER->uiHost()->message( + tr("Update Failed"), + tr("Could not start the updater.\nReason: %1.") + .arg(proc.errorString()), + UiHost::Severity::Warning); return; } diff --git a/launcher/updater/MeshMCExternalUpdater.h b/launcher/updater/MeshMCExternalUpdater.h index 01c7b9f9..86a098c0 100644 --- a/launcher/updater/MeshMCExternalUpdater.h +++ b/launcher/updater/MeshMCExternalUpdater.h @@ -30,7 +30,6 @@ #include class QSettings; -class QWidget; class MeshMCExternalUpdater : public ExternalUpdater { @@ -38,7 +37,6 @@ class MeshMCExternalUpdater : public ExternalUpdater public: /*! - * \a parent widget the dialogs are centred on; may be null. * \a appDir installation root (Application::root()). * \a dataDir where the config, the log and the markers live. * \a autoCheckDefault what "check automatically" means for an @@ -52,8 +50,7 @@ class MeshMCExternalUpdater : public ExternalUpdater * Starts the automatic check schedule, and -- when the interval is set to * "On Launch" -- performs a silent check before returning. */ - MeshMCExternalUpdater(QWidget* parent, const QString& appDir, - const QString& dataDir, + MeshMCExternalUpdater(const QString& appDir, const QString& dataDir, bool autoCheckDefault = true); ~MeshMCExternalUpdater() override; @@ -109,7 +106,6 @@ class MeshMCExternalUpdater : public ExternalUpdater QDir m_appDir; QDir m_dataDir; - QWidget* m_parent = nullptr; std::unique_ptr m_settings; QTimer m_updateTimer;