diff --git a/src/app/main.cpp b/src/app/main.cpp index 94f159a9ee..58c4b28b0f 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -51,6 +51,7 @@ inline void init_apartment_sta() noexcept #include "Application.hpp" +#include #include #include @@ -854,6 +855,9 @@ int main(int argc, char** argv) setup_limits(); setup_gpu(); setup_x11(argc, argv); + // After setup_x11: it is what decides whether this is an eglfs boot, and the + // platform reads its configuration once, when QGuiApplication starts. + score::gfx::applyDisplayConfig(); setup_gtk(); setup_suil(); setup_faust_path(); @@ -900,6 +904,13 @@ int main(int argc, char** argv) if(failsafe) app.appSettings.opengl = false; + // An appliance shows its render output, not an editor. The platform gives a + // screen to whichever window asks first and never takes it back, so the + // editor cannot simply be hidden: it must not be created. Ctrl+Alt+Shift+E + // writes the setting back and starts score again. + if(score::gfx::oneWindowPerScreen() && !score::gfx::editorUiRequested()) + app.appSettings.gui = false; + #if defined(__linux__) && !(defined(__arm__) || defined(__aarch64__)) // On linux under offscreen, etc it crashes inside // QOffscreenSurface::create diff --git a/src/lib/CMakeLists.txt b/src/lib/CMakeLists.txt index 839dba9aaa..b7bae050bb 100755 --- a/src/lib/CMakeLists.txt +++ b/src/lib/CMakeLists.txt @@ -125,6 +125,7 @@ set(HEADERS "${CMAKE_CURRENT_SOURCE_DIR}/score/document/ChangeId.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/score/document/DocumentContext.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/score/document/DocumentInterface.hpp" + "${CMAKE_CURRENT_SOURCE_DIR}/score/gfx/DisplayConfig.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/score/gfx/OpenGL.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/score/gfx/Vulkan.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/score/locking/ObjectLocker.hpp" @@ -389,6 +390,7 @@ set(SRCS "${CMAKE_CURRENT_SOURCE_DIR}/score/tools/IdentifierGeneration.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/score/command/CommandDataSerialization.cpp" +"${CMAKE_CURRENT_SOURCE_DIR}/score/gfx/DisplayConfig.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/score/gfx/OpenGL.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/score/gfx/Vulkan.cpp" diff --git a/src/lib/score/gfx/DisplayConfig.cpp b/src/lib/score/gfx/DisplayConfig.cpp new file mode 100644 index 0000000000..147f843515 --- /dev/null +++ b/src/lib/score/gfx/DisplayConfig.cpp @@ -0,0 +1,397 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace score::gfx +{ +bool DisplaySettings::isEmpty() const noexcept +{ + return outputs.isEmpty() && device.isEmpty() && headless.isEmpty() && rotation == 0 + && !hideCursor && hardwareCursor && !verticalLayout + && vulkanPhysicalDeviceIndex < 0 && vulkanDisplayIndex < 0 + && vulkanModeIndex < 0 && editorUi && platformOverride.isEmpty(); +} + +DisplayCapabilities displayCapabilities(const QString& platform) +{ + DisplayCapabilities c; + + // startsWith: the eglfs plug-in is selected as "eglfs", but a device + // integration may be appended. + if(platform.startsWith("eglfs")) + { + c.perOutputConfiguration = true; + c.requiresRestart = true; + } + else if(platform == "vkkhrdisplay") + { + c.indexedDisplaySelection = true; + c.requiresRestart = true; + } + else if(platform == "windows" || platform == "cocoa") + { + // The system owns the displays and can be asked to change them while + // running -- ChangeDisplaySettingsEx, CGCompleteDisplayConfiguration. + // Not implemented yet, which is why perOutputConfiguration stays false: + // the dialog must not offer what nothing behind it will do. + c.appliesToSystemDisplays = true; + } + + return c; +} + +QString resolvePlatform(const QString& current, const DisplaySettings& settings) +{ + if(settings.platformOverride.isEmpty()) + return current; + + if(!displayCapabilities(current).anyConfiguration()) + return current; + + return settings.platformOverride; +} + +QVector enumerateOutputs(const QString& drmRoot) +{ + QVector res; + + // cardN-HDMI-A-1 -> HDMI-A-1. The card number is the graphics device and + // changes with probe order, so it is not part of how an output is named. + // Absent on Windows, macOS, and a Linux without DRM: entryList is empty + // there and the walk simply yields nothing, falling through to Qt below. + QDir root{drmRoot}; + for(const auto& entry : root.entryList(QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name)) + { + if(!entry.startsWith("card")) + continue; + + const auto dash = entry.indexOf('-'); + if(dash < 0) + continue; + + DisplayOutput out; + out.name = entry.mid(dash + 1); + + QFile status{drmRoot + '/' + entry + "/status"}; + if(status.open(QIODevice::ReadOnly)) + out.connected = status.readAll().trimmed() == "connected"; + + QFile modes{drmRoot + '/' + entry + "/modes"}; + if(modes.open(QIODevice::ReadOnly)) + { + for(const auto& line : modes.readAll().split('\n')) + { + const auto mode = QString::fromUtf8(line.trimmed()); + // The list repeats a mode once per refresh rate it supports. + if(!mode.isEmpty() && !out.modes.contains(mode)) + out.modes.push_back(mode); + } + } + + res.push_back(std::move(out)); + } + + // No DRM: Windows, macOS, or a Linux without it. Qt knows the screens, which + // is less than the kernel would say -- one mode, the current one -- but a + // real list beats an empty dialog. Only useful once there is a + // QGuiApplication, which the settings UI has and startup does not. + if(res.isEmpty() && qGuiApp) + { + for(auto* s : QGuiApplication::screens()) + { + if(!s) + continue; + + DisplayOutput out; + out.name = s->name(); + out.connected = true; + const auto sz = s->geometry().size(); + if(!sz.isEmpty()) + out.modes.push_back( + QStringLiteral("%1x%2").arg(sz.width()).arg(sz.height())); + res.push_back(std::move(out)); + } + } + + return res; +} + +QByteArray toKmsConfig(const DisplaySettings& settings) +{ + QJsonObject root; + + if(!settings.device.isEmpty()) + root["device"] = settings.device; + if(!settings.hardwareCursor) + root["hwcursor"] = false; + if(settings.verticalLayout) + root["virtualDesktopLayout"] = "vertical"; + if(!settings.headless.isEmpty()) + root["headless"] = settings.headless; + + QJsonArray outputs; + for(const auto& o : settings.outputs) + { + if(o.name.isEmpty()) + continue; + + QJsonObject j; + j["name"] = o.name; + if(!o.mode.isEmpty()) + j["mode"] = o.mode; + if(!o.format.isEmpty()) + j["format"] = o.format; + if(o.primary) + j["primary"] = true; + if(o.hasPosition) + j["virtualPos"] = QStringLiteral("%1, %2").arg(o.x).arg(o.y); + if(o.physicalWidthMm > 0) + j["physicalWidth"] = o.physicalWidthMm; + if(o.physicalHeightMm > 0) + j["physicalHeight"] = o.physicalHeightMm; + if(!o.cloneOf.isEmpty()) + j["clones"] = o.cloneOf; + + outputs.push_back(j); + } + + if(!outputs.isEmpty()) + root["outputs"] = outputs; + + return QJsonDocument{root}.toJson(QJsonDocument::Indented); +} + +QString displayConfigPath() +{ + const auto dir + = QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation); + return dir.isEmpty() ? QString{} : dir + "/display.json"; +} + +DisplaySettings loadDisplaySettings(const QString& path) +{ + DisplaySettings s; + + QFile f{path}; + if(path.isEmpty() || !f.open(QIODevice::ReadOnly)) + return s; + + const auto doc = QJsonDocument::fromJson(f.readAll()); + if(!doc.isObject()) + return s; + + const auto root = doc.object(); + s.device = root["device"].toString(); + s.hardwareCursor = root["hwcursor"].toBool(true); + s.verticalLayout = root["virtualDesktopLayout"].toString() == "vertical"; + s.headless = root["headless"].toString(); + s.rotation = root["rotation"].toInt(); + s.hideCursor = root["hideCursor"].toBool(); + s.editorUi = root["editorUi"].toBool(true); + s.platformOverride = root["platformOverride"].toString(); + s.vulkanPhysicalDeviceIndex = root["vulkanPhysicalDeviceIndex"].toInt(-1); + s.vulkanDisplayIndex = root["vulkanDisplayIndex"].toInt(-1); + s.vulkanModeIndex = root["vulkanModeIndex"].toInt(-1); + + for(const auto& v : root["outputs"].toArray()) + { + const auto j = v.toObject(); + DisplayOutputSettings o; + o.name = j["name"].toString(); + o.mode = j["mode"].toString(); + o.format = j["format"].toString(); + o.primary = j["primary"].toBool(); + o.physicalWidthMm = j["physicalWidth"].toInt(); + o.physicalHeightMm = j["physicalHeight"].toInt(); + o.cloneOf = j["clones"].toString(); + + if(const auto pos = j["virtualPos"].toString(); !pos.isEmpty()) + { + const auto parts = pos.split(','); + if(parts.size() == 2) + { + bool okX{}, okY{}; + const int x = parts[0].trimmed().toInt(&okX); + const int y = parts[1].trimmed().toInt(&okY); + if(okX && okY) + { + o.x = x; + o.y = y; + o.hasPosition = true; + } + } + } + + s.outputs.push_back(std::move(o)); + } + + return s; +} + +bool saveDisplaySettings(const DisplaySettings& settings, const QString& path) +{ + if(path.isEmpty()) + return false; + + QJsonObject root; + if(!settings.device.isEmpty()) + root["device"] = settings.device; + root["hwcursor"] = settings.hardwareCursor; + if(settings.verticalLayout) + root["virtualDesktopLayout"] = "vertical"; + if(!settings.headless.isEmpty()) + root["headless"] = settings.headless; + if(settings.rotation != 0) + root["rotation"] = settings.rotation; + if(settings.hideCursor) + root["hideCursor"] = true; + if(!settings.editorUi) + root["editorUi"] = false; + if(!settings.platformOverride.isEmpty()) + root["platformOverride"] = settings.platformOverride; + if(settings.vulkanPhysicalDeviceIndex >= 0) + root["vulkanPhysicalDeviceIndex"] = settings.vulkanPhysicalDeviceIndex; + if(settings.vulkanDisplayIndex >= 0) + root["vulkanDisplayIndex"] = settings.vulkanDisplayIndex; + if(settings.vulkanModeIndex >= 0) + root["vulkanModeIndex"] = settings.vulkanModeIndex; + + QJsonArray outputs; + for(const auto& o : settings.outputs) + { + QJsonObject j; + j["name"] = o.name; + if(!o.mode.isEmpty()) + j["mode"] = o.mode; + if(!o.format.isEmpty()) + j["format"] = o.format; + if(o.primary) + j["primary"] = true; + if(o.hasPosition) + j["virtualPos"] = QStringLiteral("%1, %2").arg(o.x).arg(o.y); + if(o.physicalWidthMm > 0) + j["physicalWidth"] = o.physicalWidthMm; + if(o.physicalHeightMm > 0) + j["physicalHeight"] = o.physicalHeightMm; + if(!o.cloneOf.isEmpty()) + j["clones"] = o.cloneOf; + outputs.push_back(j); + } + root["outputs"] = outputs; + + QDir{}.mkpath(QFileInfo{path}.absolutePath()); + + QFile f{path}; + if(!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) + return false; + + const auto data = QJsonDocument{root}.toJson(QJsonDocument::Indented); + return f.write(data) == data.size(); +} + +bool editorUiRequested() +{ + return loadDisplaySettings(displayConfigPath()).editorUi; +} + +bool oneWindowPerScreen() noexcept +{ + // main() asks before there is a QGuiApplication, so the environment is the + // only answer available then; afterwards the platform itself is the truth, + // since a -platform argument never reaches the environment. + const auto p = qGuiApp ? QGuiApplication::platformName() + : QString::fromUtf8(qgetenv("QT_QPA_PLATFORM")); + return p.startsWith("eglfs") || p == "vkkhrdisplay" || p == "linuxfb" + || p == "minimalegl"; +} + +void restartIntoEditor() +{ + const auto path = displayConfigPath(); + auto settings = loadDisplaySettings(path); + + settings.editorUi = true; + // vkkhrdisplay creates a window for a widget and then draws nothing into it, + // so coming back to the editor there means coming back under eglfs. + if(displayCapabilities(QGuiApplication::platformName()).indexedDisplaySelection) + settings.platformOverride = QStringLiteral("eglfs"); + + saveDisplaySettings(settings, path); + +#if QT_CONFIG(process) + QProcess::startDetached( + QCoreApplication::applicationFilePath(), QCoreApplication::arguments().mid(1)); + QCoreApplication::quit(); +#else + // Nowhere that can start a process is anywhere this is reachable from: the + // platforms that hand a screen to one window are all desktop-class. The + // setting is still written, so the next launch honours it. + qWarning() << "Display settings saved; restart score for them to take effect."; +#endif +} + +void applyDisplayConfig() +{ +#if defined(__linux__) + const auto path = displayConfigPath(); + if(path.isEmpty() || !QFile::exists(path)) + return; + + const auto settings = loadDisplaySettings(path); + if(settings.isEmpty()) + return; + + if(const auto chosen = resolvePlatform( + QString::fromUtf8(qgetenv("QT_QPA_PLATFORM")), settings); + !chosen.isEmpty()) + qputenv("QT_QPA_PLATFORM", chosen.toUtf8()); + + // Only meaningful for a platform that reads it: where a window manager owns + // the display, none of this applies and setting it would be a lie. + const auto platform = QString::fromUtf8(qgetenv("QT_QPA_PLATFORM")); + const auto caps = displayCapabilities(platform); + + if(caps.perOutputConfiguration) + { + const auto kms = toKmsConfig(settings); + const auto kmsPath = QFileInfo{path}.absolutePath() + "/display-kms.json"; + QFile f{kmsPath}; + if(f.open(QIODevice::WriteOnly | QIODevice::Truncate) + && f.write(kms) == kms.size()) + { + f.close(); + qputenv("QT_QPA_EGLFS_KMS_CONFIG", kmsPath.toUtf8()); + } + + if(settings.rotation != 0) + qputenv("QT_QPA_EGLFS_ROTATION", QByteArray::number(settings.rotation)); + if(settings.hideCursor) + qputenv("QT_QPA_EGLFS_HIDECURSOR", "1"); + } + else if(caps.indexedDisplaySelection) + { + // All this platform has. The connector names and the layout above have no + // counterpart here and are deliberately not approximated. + if(settings.vulkanPhysicalDeviceIndex >= 0) + qputenv( + "QT_VK_PHYSICAL_DEVICE_INDEX", + QByteArray::number(settings.vulkanPhysicalDeviceIndex)); + if(settings.vulkanDisplayIndex >= 0) + qputenv("QT_VK_DISPLAY_INDEX", QByteArray::number(settings.vulkanDisplayIndex)); + if(settings.vulkanModeIndex >= 0) + qputenv("QT_VK_MODE_INDEX", QByteArray::number(settings.vulkanModeIndex)); + } +#endif +} +} diff --git a/src/lib/score/gfx/DisplayConfig.hpp b/src/lib/score/gfx/DisplayConfig.hpp new file mode 100644 index 0000000000..4032df3e9d --- /dev/null +++ b/src/lib/score/gfx/DisplayConfig.hpp @@ -0,0 +1,208 @@ +#pragma once +#include +#include + +#include + +namespace score::gfx +{ +/** + * @brief One physical output, as the machine reports it. + * + * `name` is the connector -- "HDMI-A-1", "DP-1" -- which is what a display is + * actually identified by. Not an index into a screen list: that order changes + * between machines and between boots, so a document that names one is a + * document that lands on the wrong projector somewhere else. + */ +struct DisplayOutput +{ + QString name; + bool connected{}; + //! Modes the connector reports, best first, as "1920x1080". + QVector modes; +}; + +/** + * @brief What the user asked of one output. + * + * `mode` follows the platform's own vocabulary: empty means leave it alone, + * "off" and "skip" disable it, "preferred" and "current" defer to the display, + * and anything else is "1920x1080" or "1920x1080@60". + */ +struct DisplayOutputSettings +{ + QString name; + QString mode; + QString format; + bool primary{}; + //! Where this output sits in the arrangement, in pixels. Unset means the + //! outputs are laid out left to right in order. + int x{}; + int y{}; + bool hasPosition{}; + int physicalWidthMm{}; + int physicalHeightMm{}; + QString cloneOf; +}; + +//! Settings that belong to the machine rather than to one output. +struct SCORE_LIB_BASE_EXPORT DisplaySettings +{ + QString device; + bool hardwareCursor{true}; + bool verticalLayout{}; + //! Render with no output at all, as "1920x1080". For a machine that computes + //! frames for somebody else. + QString headless; + //! Degrees, applied by the platform to software-rendered content. + int rotation{}; + bool hideCursor{}; + QVector outputs; + + /** + * Whether the editor is shown on a platform where a screen holds one window. + * + * False is the appliance: the render output takes the screen, because that + * is what the machine is for. There is no switching between them while + * running -- the platform hands a screen to whichever window asks first and + * never takes it back -- so changing this writes the file and starts score + * again, which is the only thing that can reorder them. + */ + bool editorUi{true}; + + /** + * Which QPA to start with, when score is choosing one for itself. + * + * The way back from a display that cannot show a user interface: + * vkkhrdisplay draws no widgets at all, so returning to the editor there + * means coming up under eglfs instead. Ignored where a window manager + * already decided. + */ + QString platformOverride; + + /** + * vkkhrdisplay addresses displays by index, not by name, and offers nothing + * else: no JSON, no per-output layout, no cloning, and one screen at a time. + * There is no honest way to derive these from the connector names above -- + * the order comes from the Vulkan driver -- so they are their own settings + * rather than a translation. -1 means "leave it to the platform". + */ + int vulkanPhysicalDeviceIndex{-1}; + int vulkanDisplayIndex{-1}; + int vulkanModeIndex{-1}; + + bool isEmpty() const noexcept; +}; + +//! Which of the above a platform can actually honour. The settings UI asks +//! this rather than hiding things behind a platform-name comparison of its own. +struct DisplayCapabilities +{ + //! Connector names, modes, layout, cloning. eglfs through its JSON; Windows + //! through ChangeDisplaySettingsEx; macOS through CGDisplayConfiguration. + bool perOutputConfiguration{}; + + //! vkkhrdisplay, which addresses a display by index and offers nothing else. + bool indexedDisplaySelection{}; + + /** + * Whether the configuration only takes hold when the process starts again. + * + * True on the embedded platforms, where these are read once by the platform + * plug-in and there is no way to revisit them. False where the system owns + * the displays and can be asked to change them while running -- which also + * means the change can be undone, so those platforms want a "keep this + * setting?" confirmation that the embedded ones cannot offer. + */ + bool requiresRestart{}; + + //! Whether score may change the machine's display setup at all. On a desktop + //! the answer is "only if asked": rearranging somebody's monitors because a + //! score was opened would be hostile. + bool appliesToSystemDisplays{}; + + //! Whether anything here applies at all. + bool anyConfiguration() const noexcept + { + return perOutputConfiguration || indexedDisplaySelection; + } +}; + +//! What `platform` supports. Takes the name so that it can be asked about a +//! platform other than the running one -- an appliance is configured from a +//! desktop, where QGuiApplication::platformName() says "xcb". +SCORE_LIB_BASE_EXPORT DisplayCapabilities displayCapabilities(const QString& platform); + +/** + * @brief The platform to actually start with. + * + * `current` is what has been chosen so far -- empty when a window manager is + * present and score is not choosing. An override only applies when score was + * going to pick an embedded platform anyway: a saved appliance setting must + * never hijack a desktop session that happens to read the same file. + */ +SCORE_LIB_BASE_EXPORT QString +resolvePlatform(const QString& current, const DisplaySettings& settings); + +/** + * @brief Come back up with the editor visible, and with a platform that can + * draw it. + * + * Writes the settings and relaunches. Nothing quieter is possible: the window + * that holds a screen holds it for the life of the process. + */ +SCORE_LIB_BASE_EXPORT void restartIntoEditor(); + +//! Whether the saved configuration asks for an editor. Answered from the file +//! rather than from settings: this is read before there is a QApplication. +SCORE_LIB_BASE_EXPORT bool editorUiRequested(); + +/** + * @brief Whether a screen here backs exactly one window. + * + * With no windowing system -- eglfs, vkkhrdisplay, linuxfb -- a screen is the + * scanout buffer and Qt gives it to whichever window asks first. The second + * one does not fail politely: QEglFSWindow::create() calls qFatal and the + * process is gone. So this is asked before opening a window, never recovered + * from after. + */ +SCORE_LIB_BASE_EXPORT bool oneWindowPerScreen() noexcept; + +/** + * @brief The outputs this machine has. + * + * Read from sysfs rather than from Qt: this has to answer before there is a + * QGuiApplication to ask, and it must work while another process holds the + * display. `drmRoot` exists so the walk can be pointed at a fixture. + */ +SCORE_LIB_BASE_EXPORT QVector +enumerateOutputs(const QString& drmRoot = QStringLiteral("/sys/class/drm")); + +/** + * @brief `settings` as the JSON the KMS platform reads. + * + * Only what the user actually set is written: an absent key means "whatever + * the driver decided", which is a better default than anything score could + * invent. + */ +SCORE_LIB_BASE_EXPORT QByteArray toKmsConfig(const DisplaySettings& settings); + +//! Where the configuration is kept. Not QSettings: this is read before there +//! is a QApplication to give QSettings its organisation name. +SCORE_LIB_BASE_EXPORT QString displayConfigPath(); + +SCORE_LIB_BASE_EXPORT DisplaySettings loadDisplaySettings(const QString& path); +SCORE_LIB_BASE_EXPORT bool +saveDisplaySettings(const DisplaySettings& settings, const QString& path); + +/** + * @brief Put the configuration into effect, if there is one. + * + * Every one of these is read once, when the platform plug-in starts, so this + * has to run before QGuiApplication is constructed and does nothing useful + * afterwards. Under eglfs it writes the KMS JSON next to the settings and + * points the platform at it; under vkkhrdisplay it sets the three indices that + * platform understands. A no-op where a window manager owns the display. + */ +SCORE_LIB_BASE_EXPORT void applyDisplayConfig(); +} diff --git a/src/plugins/score-plugin-gfx/CMakeLists.txt b/src/plugins/score-plugin-gfx/CMakeLists.txt index d41ba18c8d..f59b420b96 100644 --- a/src/plugins/score-plugin-gfx/CMakeLists.txt +++ b/src/plugins/score-plugin-gfx/CMakeLists.txt @@ -411,6 +411,8 @@ set(SRCS Gfx/Graph/RenderedRawRasterPipelineNode.cpp Gfx/Graph/RenderedVSANode.cpp Gfx/Graph/ScreenNode.cpp + Gfx/Graph/ScreenPlacement.cpp + Gfx/Settings/DisplayConfigDialog.cpp Gfx/Graph/ShaderCache.cpp Gfx/Graph/SimpleRenderedISFNode.cpp Gfx/Graph/TextNode.cpp diff --git a/src/plugins/score-plugin-gfx/Gfx/GfxApplicationPlugin.cpp b/src/plugins/score-plugin-gfx/Gfx/GfxApplicationPlugin.cpp index 7fdc3e668a..6c6d361f80 100644 --- a/src/plugins/score-plugin-gfx/Gfx/GfxApplicationPlugin.cpp +++ b/src/plugins/score-plugin-gfx/Gfx/GfxApplicationPlugin.cpp @@ -1,5 +1,12 @@ #include "GfxApplicationPlugin.hpp" +#include + +#include + +#include +#include + #include #include @@ -22,6 +29,23 @@ ApplicationPlugin::ApplicationPlugin(const score::GUIApplicationContext& app) : GUIApplicationPlugin{app} { // Early: the canvas watchers have to be in place before a context can be lost. + installEditorEscapeHatch(); +} + +void ApplicationPlugin::installEditorEscapeHatch() +{ + // A machine with no window manager can end up showing a render output and + // nothing else -- by configuration, or because a display setting was wrong + // and there is no desktop to fix it from. Without a way back the only remedy + // is a keyboard on another machine, and on an appliance there may not be one. + if(!score::gfx::oneWindowPerScreen()) + return; + + auto* shortcut = new QShortcut{ + QKeySequence{Qt::CTRL | Qt::ALT | Qt::SHIFT | Qt::Key_E}, qApp}; + shortcut->setContext(Qt::ApplicationShortcut); + QObject::connect( + shortcut, &QShortcut::activated, qApp, [] { score::gfx::restartIntoEditor(); }); } void ApplicationPlugin::on_createdDocument(score::Document& doc) diff --git a/src/plugins/score-plugin-gfx/Gfx/GfxApplicationPlugin.hpp b/src/plugins/score-plugin-gfx/Gfx/GfxApplicationPlugin.hpp index c6a4f6b02b..b207a0827f 100644 --- a/src/plugins/score-plugin-gfx/Gfx/GfxApplicationPlugin.hpp +++ b/src/plugins/score-plugin-gfx/Gfx/GfxApplicationPlugin.hpp @@ -26,5 +26,8 @@ class ApplicationPlugin final : public score::GUIApplicationPlugin protected: void on_createdDocument(score::Document& doc) override; + +private: + void installEditorEscapeHatch(); }; } diff --git a/src/plugins/score-plugin-gfx/Gfx/Graph/MultiWindowNode.cpp b/src/plugins/score-plugin-gfx/Gfx/Graph/MultiWindowNode.cpp index ec739044ca..6272a1233d 100644 --- a/src/plugins/score-plugin-gfx/Gfx/Graph/MultiWindowNode.cpp +++ b/src/plugins/score-plugin-gfx/Gfx/Graph/MultiWindowNode.cpp @@ -5,6 +5,7 @@ #include #include +#include #include #include #include @@ -984,6 +985,27 @@ void MultiWindowNode::createOutput(score::gfx::OutputConfiguration conf) targetScreen = screens[mapping.screenIndex]; } + if(oneWindowPerScreen()) + { + // Every window here is fullscreen on a screen of its own, whatever the + // mapping asked for: a second one on a screen already taken aborts the + // process from inside Qt. occupiedScreens() sees the windows shown by + // the previous turns of this loop, so they spread out one per screen. + auto* scr = freeScreen(targetScreen, qApp->screens(), occupiedScreens()); + if(!scr) + { + qWarning() << "Gfx: no free screen for window" << i << "on platform" + << QGuiApplication::platformName() + << "- one window per screen. Not rendering to it."; + continue; + } + + wo.window->setScreen(scr); + wo.window->setGeometry(scr->geometry()); + wo.window->show(); + continue; + } + if(mapping.fullscreen) { if(targetScreen) diff --git a/src/plugins/score-plugin-gfx/Gfx/Graph/ScreenNode.cpp b/src/plugins/score-plugin-gfx/Gfx/Graph/ScreenNode.cpp index 80da2e0863..d393df1ae2 100644 --- a/src/plugins/score-plugin-gfx/Gfx/Graph/ScreenNode.cpp +++ b/src/plugins/score-plugin-gfx/Gfx/Graph/ScreenNode.cpp @@ -2,12 +2,16 @@ #include #include #include +#include #include #include #include #include +#include +#include + #include #ifndef QT_NO_OPENGL @@ -471,7 +475,10 @@ void ScreenNode::stopRendering() void ScreenNode::setRenderer(std::shared_ptr r) { - m_window->state->renderer = r; + // No state until the window has been exposed at least once, and a window + // that could not be given a screen never will be. + if(m_window && m_window->state) + m_window->state->renderer = r; } RenderList* ScreenNode::renderer() const @@ -685,6 +692,26 @@ void ScreenNode::createOutput(score::gfx::OutputConfiguration conf) if(!m_title.isEmpty()) m_window->setTitle(m_title); + if(oneWindowPerScreen()) + { + auto* scr = freeScreen(m_screen, QGuiApplication::screens(), occupiedScreens()); + if(!scr) + { + qWarning() << "Gfx: no free screen for output" << m_title << "on platform" + << QGuiApplication::platformName() + << "- one window per screen. Not rendering to it."; + return; + } + + // The geometry is what decides the screen here, not setScreen(): with no + // window manager the platform makes every window fullscreen and rederives + // the screen from where it lands, undoing setScreen() on its own. + m_window->setScreen(scr); + m_window->setGeometry(scr->geometry()); + m_window->show(); + return; + } + if(m_screen) { m_window->setScreen(m_screen); diff --git a/src/plugins/score-plugin-gfx/Gfx/Graph/ScreenPlacement.cpp b/src/plugins/score-plugin-gfx/Gfx/Graph/ScreenPlacement.cpp new file mode 100644 index 0000000000..2186f863f3 --- /dev/null +++ b/src/plugins/score-plugin-gfx/Gfx/Graph/ScreenPlacement.cpp @@ -0,0 +1,35 @@ +#include "ScreenPlacement.hpp" + +#include +#include +#include + +namespace score::gfx +{ +QSet occupiedScreens() +{ + QSet taken; + for(auto* w : QGuiApplication::topLevelWindows()) + { + // handle(): a QWindow that was never shown has no platform surface and so + // holds no screen. + if(w && w->handle()) + if(auto* s = w->screen()) + taken.insert(s); + } + return taken; +} + +QScreen* freeScreen( + QScreen* preferred, const QList& all, const QSet& taken) noexcept +{ + if(preferred && !taken.contains(preferred)) + return preferred; + + for(auto* s : all) + if(s && !taken.contains(s)) + return s; + + return nullptr; +} +} diff --git a/src/plugins/score-plugin-gfx/Gfx/Graph/ScreenPlacement.hpp b/src/plugins/score-plugin-gfx/Gfx/Graph/ScreenPlacement.hpp new file mode 100644 index 0000000000..7bb0f1ee5c --- /dev/null +++ b/src/plugins/score-plugin-gfx/Gfx/Graph/ScreenPlacement.hpp @@ -0,0 +1,25 @@ +#pragma once +#include + +#include +#include + +#include + +class QScreen; + +namespace score::gfx +{ + +//! The screens already carrying a window of this process. +SCORE_PLUGIN_GFX_EXPORT QSet occupiedScreens(); + +/** + * @brief A screen a new window may be given, or nullptr when they are all taken. + * + * `preferred` -- the screen the user picked -- wins whenever it is free. + */ +SCORE_PLUGIN_GFX_EXPORT QScreen* freeScreen( + QScreen* preferred, const QList& all, + const QSet& taken) noexcept; +} diff --git a/src/plugins/score-plugin-gfx/Gfx/Settings/DisplayConfigDialog.cpp b/src/plugins/score-plugin-gfx/Gfx/Settings/DisplayConfigDialog.cpp new file mode 100644 index 0000000000..a3835c5778 --- /dev/null +++ b/src/plugins/score-plugin-gfx/Gfx/Settings/DisplayConfigDialog.cpp @@ -0,0 +1,280 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Gfx::Settings +{ +namespace +{ +enum Column +{ + Name = 0, + Status, + Mode, + Format, + Primary, + PosX, + PosY, + ColumnCount +}; + +//! The vocabulary the platform itself parses, so it is offered rather than +//! invented. An empty choice writes nothing and leaves the driver alone. +const QStringList& modeChoices() +{ + static const QStringList l{ + QStringLiteral(""), QStringLiteral("preferred"), QStringLiteral("current"), + QStringLiteral("off"), QStringLiteral("skip")}; + return l; +} + +const QStringList& formatChoices() +{ + static const QStringList l{ + QStringLiteral(""), QStringLiteral("xrgb8888"), + QStringLiteral("argb8888"), QStringLiteral("xbgr8888"), + QStringLiteral("abgr8888"), QStringLiteral("rgb565"), + QStringLiteral("bgr565"), QStringLiteral("xrgb2101010"), + QStringLiteral("argb2101010")}; + return l; +} +} + +DisplayConfigWidget::DisplayConfigWidget(QWidget* parent) + : QWidget{parent} +{ + + auto* lay = new QVBoxLayout{this}; + + const auto caps = score::gfx::displayCapabilities(QGuiApplication::platformName()); + auto* note = new QLabel{this}; + note->setWordWrap(true); + if(caps.perOutputConfiguration) + note->setText( + tr("These settings take effect when score restarts: the platform reads " + "them once, at startup.")); + else if(caps.indexedDisplaySelection) + note->setText(tr( + "This platform selects a display by index and cannot be told about " + "connectors, layout or cloning. Only the Vulkan section below applies. " + "Changes take effect when score restarts.")); + else if(caps.appliesToSystemDisplays) + note->setText( + tr("This system owns its displays and can rearrange them while running, " + "so score does not do it behind your back: nothing here is applied " + "here yet. The settings are saved, and take effect on a machine that " + "boots without a window manager — which is what they are for.")); + else + note->setText( + tr("This machine has a window manager, which owns the displays: nothing " + "here applies to it. The settings are saved for a machine that boots " + "without one — configure them here, and they take effect there.")); + lay->addWidget(note); + + m_outputs = score::gfx::enumerateOutputs(); + + m_table = new QTableWidget{(int)m_outputs.size(), ColumnCount, this}; + m_table->setHorizontalHeaderLabels( + {tr("Output"), tr("Status"), tr("Mode"), tr("Format"), tr("Primary"), tr("X"), + tr("Y")}); + m_table->horizontalHeader()->setSectionResizeMode(Name, QHeaderView::Stretch); + m_table->verticalHeader()->setVisible(false); + + for(int i = 0; i < m_outputs.size(); i++) + { + const auto& o = m_outputs[i]; + + auto* name = new QTableWidgetItem{o.name}; + name->setFlags(name->flags() & ~Qt::ItemIsEditable); + m_table->setItem(i, Name, name); + + auto* st = new QTableWidgetItem{o.connected ? tr("connected") : tr("disconnected")}; + st->setFlags(st->flags() & ~Qt::ItemIsEditable); + m_table->setItem(i, Status, st); + + auto* mode = new QComboBox; + mode->setEditable(true); + mode->addItems(modeChoices()); + // The modes this connector actually reports, after the keywords. + for(const auto& m : o.modes) + mode->addItem(m); + m_table->setCellWidget(i, Mode, mode); + + auto* fmt = new QComboBox; + fmt->addItems(formatChoices()); + m_table->setCellWidget(i, Format, fmt); + + auto* prim = new QCheckBox; + m_table->setCellWidget(i, Primary, prim); + + for(int c : {PosX, PosY}) + { + auto* sp = new QSpinBox; + sp->setRange(-32768, 32768); + sp->setSpecialValueText(tr("auto")); + sp->setMinimum(-32768); + sp->setValue(-32768); // the special value: unset + m_table->setCellWidget(i, c, sp); + } + } + lay->addWidget(m_table, 1); + + auto* globals = new QGroupBox{tr("This machine"), this}; + auto* gl = new QFormLayout{globals}; + m_editorUi = new QCheckBox; + m_editorUi->setChecked(true); + m_editorUi->setToolTip( + tr("Off makes this an appliance: the render output takes the screen and " + "there is no editor. Ctrl+Alt+Shift+E brings the editor back.")); + gl->addRow(tr("Show the editor"), m_editorUi); + m_hwCursor = new QCheckBox; + m_hwCursor->setChecked(true); + gl->addRow(tr("Hardware cursor"), m_hwCursor); + m_hideCursor = new QCheckBox; + gl->addRow(tr("Hide the cursor"), m_hideCursor); + m_vertical = new QCheckBox; + gl->addRow(tr("Stack screens vertically"), m_vertical); + m_rotation = new QComboBox; + m_rotation->addItems({"0", "90", "180", "270"}); + gl->addRow(tr("Rotation"), m_rotation); + m_headless = new QLineEdit; + m_headless->setPlaceholderText(tr("e.g. 1920x1080 — render with no output at all")); + gl->addRow(tr("Headless"), m_headless); + m_device = new QLineEdit; + m_device->setPlaceholderText(tr("e.g. /dev/dri/card0 — leave empty to autodetect")); + gl->addRow(tr("DRM device"), m_device); + lay->addWidget(globals); + + auto* vk = new QGroupBox{tr("Vulkan display (vkkhrdisplay)"), this}; + auto* vl = new QFormLayout{vk}; + auto mkIndex = [](QSpinBox*& sp, QFormLayout* l, const QString& label) { + sp = new QSpinBox; + sp->setRange(-1, 64); + sp->setValue(-1); + sp->setSpecialValueText(tr("leave to the platform")); + l->addRow(label, sp); + }; + mkIndex(m_vkDevice, vl, tr("Physical device index")); + mkIndex(m_vkDisplay, vl, tr("Display index")); + mkIndex(m_vkMode, vl, tr("Mode index")); + lay->addWidget(vk); + + auto* save = new QPushButton{tr("Save")}; + connect(save, &QPushButton::clicked, this, &DisplayConfigWidget::save); + auto* revert = new QPushButton{tr("Revert")}; + connect(revert, &QPushButton::clicked, this, &DisplayConfigWidget::load); + auto* buttons = new QHBoxLayout; + buttons->addStretch(1); + buttons->addWidget(revert); + buttons->addWidget(save); + lay->addLayout(buttons); + + load(); +} + +void DisplayConfigWidget::load() +{ + const auto s = score::gfx::loadDisplaySettings(score::gfx::displayConfigPath()); + + m_editorUi->setChecked(s.editorUi); + m_hwCursor->setChecked(s.hardwareCursor); + m_hideCursor->setChecked(s.hideCursor); + m_vertical->setChecked(s.verticalLayout); + m_rotation->setCurrentText(QString::number(s.rotation)); + m_headless->setText(s.headless); + m_device->setText(s.device); + m_vkDevice->setValue(s.vulkanPhysicalDeviceIndex); + m_vkDisplay->setValue(s.vulkanDisplayIndex); + m_vkMode->setValue(s.vulkanModeIndex); + + // Matched by connector name: a saved configuration may name an output this + // machine does not have, which is the normal case when an appliance is + // configured from a laptop. Those rows simply are not shown -- and are + // dropped on save, which is why saving from the wrong machine is destructive + // and the dialog is not a place to do it casually. + for(const auto& o : s.outputs) + { + for(int i = 0; i < m_outputs.size(); i++) + { + if(m_outputs[i].name != o.name) + continue; + + if(auto* c = qobject_cast(m_table->cellWidget(i, Mode))) + c->setCurrentText(o.mode); + if(auto* c = qobject_cast(m_table->cellWidget(i, Format))) + c->setCurrentText(o.format); + if(auto* c = qobject_cast(m_table->cellWidget(i, Primary))) + c->setChecked(o.primary); + if(o.hasPosition) + { + if(auto* c = qobject_cast(m_table->cellWidget(i, PosX))) + c->setValue(o.x); + if(auto* c = qobject_cast(m_table->cellWidget(i, PosY))) + c->setValue(o.y); + } + break; + } + } +} + +score::gfx::DisplaySettings DisplayConfigWidget::collect() const +{ + score::gfx::DisplaySettings s; + s.editorUi = m_editorUi->isChecked(); + s.hardwareCursor = m_hwCursor->isChecked(); + s.hideCursor = m_hideCursor->isChecked(); + s.verticalLayout = m_vertical->isChecked(); + s.rotation = m_rotation->currentText().toInt(); + s.headless = m_headless->text().trimmed(); + s.device = m_device->text().trimmed(); + s.vulkanPhysicalDeviceIndex = m_vkDevice->value(); + s.vulkanDisplayIndex = m_vkDisplay->value(); + s.vulkanModeIndex = m_vkMode->value(); + + for(int i = 0; i < m_outputs.size(); i++) + { + score::gfx::DisplayOutputSettings o; + o.name = m_outputs[i].name; + + if(auto* c = qobject_cast(m_table->cellWidget(i, Mode))) + o.mode = c->currentText().trimmed(); + if(auto* c = qobject_cast(m_table->cellWidget(i, Format))) + o.format = c->currentText().trimmed(); + if(auto* c = qobject_cast(m_table->cellWidget(i, Primary))) + o.primary = c->isChecked(); + + auto* x = qobject_cast(m_table->cellWidget(i, PosX)); + auto* y = qobject_cast(m_table->cellWidget(i, PosY)); + if(x && y && x->value() != x->minimum() && y->value() != y->minimum()) + { + o.x = x->value(); + o.y = y->value(); + o.hasPosition = true; + } + + // An output nobody said anything about is left out entirely, so the file + // stays a record of decisions rather than of defaults. + if(!o.mode.isEmpty() || !o.format.isEmpty() || o.primary || o.hasPosition) + s.outputs.push_back(std::move(o)); + } + + return s; +} + +void DisplayConfigWidget::save() +{ + score::gfx::saveDisplaySettings(collect(), score::gfx::displayConfigPath()); +} +} diff --git a/src/plugins/score-plugin-gfx/Gfx/Settings/DisplayConfigDialog.hpp b/src/plugins/score-plugin-gfx/Gfx/Settings/DisplayConfigDialog.hpp new file mode 100644 index 0000000000..be7f6f6873 --- /dev/null +++ b/src/plugins/score-plugin-gfx/Gfx/Settings/DisplayConfigDialog.hpp @@ -0,0 +1,53 @@ +#pragma once +#include + +#include +#include + +class QCheckBox; +class QComboBox; +class QLineEdit; +class QSpinBox; +class QTableWidget; + +namespace Gfx::Settings +{ +/** + * @brief Setting up the displays of a machine with no window manager. + * + * On an appliance there is no xrandr and no display panel: what the platform + * reads when it starts is the only say anyone gets. So this is not a normal + * settings page -- on the embedded platforms nothing here takes effect until + * score is restarted, and on a desktop none of it takes effect at all. It says + * so rather than appearing to work. + * + * The outputs come from the kernel, so the list is real on any machine with + * DRM, including the desktop the appliance is being configured from. + */ +class DisplayConfigWidget final : public QWidget +{ +public: + explicit DisplayConfigWidget(QWidget* parent = nullptr); + + //! Write the current state to disk. Bound to the page's Save button. + void save(); + +private: + void load(); + score::gfx::DisplaySettings collect() const; + + QVector m_outputs; + + QTableWidget* m_table{}; + QCheckBox* m_editorUi{}; + QCheckBox* m_hwCursor{}; + QCheckBox* m_hideCursor{}; + QCheckBox* m_vertical{}; + QComboBox* m_rotation{}; + QLineEdit* m_headless{}; + QLineEdit* m_device{}; + QSpinBox* m_vkDevice{}; + QSpinBox* m_vkDisplay{}; + QSpinBox* m_vkMode{}; +}; +} diff --git a/src/plugins/score-plugin-gfx/Gfx/Settings/View.cpp b/src/plugins/score-plugin-gfx/Gfx/Settings/View.cpp index b9372a90fb..8f26ff3556 100644 --- a/src/plugins/score-plugin-gfx/Gfx/Settings/View.cpp +++ b/src/plugins/score-plugin-gfx/Gfx/Settings/View.cpp @@ -1,6 +1,7 @@ // This is an open source non-commercial project. Dear PVS-Studio, please check // it. PVS-Studio Static Code Analyzer for C, C++ and C#: http://www.viva64.com +#include #include #include #include @@ -13,6 +14,7 @@ #include #include #include +#include #include W_OBJECT_IMPL(Gfx::Settings::View) @@ -38,11 +40,15 @@ View::View() static constexpr int buffers_values[]{1, 2, 3}; SETTINGS_UI_NUM_COMBOBOX_SETUP("Buffer count", Buffers, buffers_values); + + m_tabs = new QTabWidget; + m_tabs->addTab(m_widg, tr("Rendering")); + m_tabs->addTab(new DisplayConfigWidget, tr("Displays")); } QWidget* View::getWidget() { - return m_widg; + return m_tabs; } SETTINGS_UI_COMBOBOX_IMPL(GraphicsApi) diff --git a/src/plugins/score-plugin-gfx/Gfx/Settings/View.hpp b/src/plugins/score-plugin-gfx/Gfx/Settings/View.hpp index 7e90502775..861e6c317a 100644 --- a/src/plugins/score-plugin-gfx/Gfx/Settings/View.hpp +++ b/src/plugins/score-plugin-gfx/Gfx/Settings/View.hpp @@ -3,6 +3,7 @@ #include +class QTabWidget; namespace score { class FormWidget; @@ -25,6 +26,7 @@ class View : public score::GlobalSettingsView private: QWidget* getWidget() override; + QTabWidget* m_tabs{}; score::FormWidget* m_widg{}; }; diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index acae980820..da7cf81d69 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -224,12 +224,13 @@ if(TARGET score_plugin_faust) PLUGINS score_plugin_faust) target_include_directories(test_unit_faust_dsp PRIVATE "${SCORE_ROOT_SOURCE_DIR}/src/plugins/score-plugin-faust") - # score_plugin_faust keeps FAUST_INCLUDE_DIR PRIVATE, so linking it does not - # bring the faust headers along; FaustDspTest.cpp reaches libossia headers - # that include directly. The find_package branch below - # already does this. - target_include_directories(test_unit_faust_dsp SYSTEM PRIVATE - ${FAUST_INCLUDE_DIR}) + # The plug-in keeps faust's own include directory private, so linking it does + # not hand the headers over. Distributions put them somewhere the compiler + # looks anyway; an SDK build does not. + if(FAUST_INCLUDE_DIR) + target_include_directories(test_unit_faust_dsp SYSTEM PRIVATE + ${FAUST_INCLUDE_DIR}) + endif() else() find_package(Faust QUIET) if(FAUST_FOUND AND TARGET score_lib_process) @@ -387,6 +388,16 @@ if(TARGET pipewire::pipewire) endif() endif() +# --- output windows on a board with no window manager ---------------------- +# Under eglfs a screen backs one window and the second one qFatals, so which +# screen an output gets has to be decided before it is shown. +if(TARGET score_plugin_gfx) + score_add_test(test_unit_screen_placement + SOURCES ScreenPlacementTest.cpp + APP + PLUGINS score_plugin_gfx) +endif() + # --- Plug-in scanner ("puppet") protocol helpers ---------------------------- # Unescaped plug-in metadata used to break entire scan replies; the request # id / port / token argv protocol routes replies to the right scan session. @@ -616,3 +627,8 @@ score_add_test(test_unit_dshow_subtype SOURCES DirectShowSubtypeResolveTest.cpp PLUGINS score_plugin_gfx LIBS avutil avcodec) +# --- configuring the displays of a machine with no window manager ---------- +# The KMS JSON and the vkkhrdisplay indices are read once at platform startup; +# producing exactly what each understands is the whole feature. +score_add_test(test_unit_display_config + SOURCES DisplayConfigTest.cpp) diff --git a/tests/unit/DisplayConfigTest.cpp b/tests/unit/DisplayConfigTest.cpp new file mode 100644 index 0000000000..6b91e478df --- /dev/null +++ b/tests/unit/DisplayConfigTest.cpp @@ -0,0 +1,282 @@ +// Configuring the displays of a machine that has no window manager. +// +// On an appliance there is no xrandr and no display panel: the platform reads +// its configuration once at startup and that is the only chance to say +// anything. So this is about producing exactly what eglfs and vkkhrdisplay +// each understand -- and, just as much, about not producing what they don't. + +#include + +#include +#include +#include +#include +#include +#include + +#include + +using namespace score::gfx; + +namespace +{ +//! A /sys/class/drm as the kernel lays one out. +void writeConnector( + const QString& root, const QString& card, const QString& status, + const QString& modes) +{ + QDir{}.mkpath(root + '/' + card); + QFile s{root + '/' + card + "/status"}; + REQUIRE(s.open(QIODevice::WriteOnly)); + s.write(status.toUtf8() + "\n"); + s.close(); + + QFile m{root + '/' + card + "/modes"}; + REQUIRE(m.open(QIODevice::WriteOnly)); + m.write(modes.toUtf8()); +} +} + +TEST_CASE("Outputs are read from the kernel, by connector name", "[gfx]") +{ + QTemporaryDir tmp; + REQUIRE(tmp.isValid()); + const auto root = tmp.path(); + + writeConnector(root, "card0-HDMI-A-1", "connected", "1920x1080\n1920x1080\n1280x720\n"); + writeConnector(root, "card0-HDMI-A-2", "disconnected", ""); + writeConnector(root, "card1-DP-1", "connected", "3840x2160\n"); + // Not a connector: the card itself, and whatever else lives here. + QDir{}.mkpath(root + "/card0"); + QDir{}.mkpath(root + "/version"); + + const auto outs = enumerateOutputs(root); + + REQUIRE(outs.size() == 3); + + // The card number is probe order, not identity: it is stripped. + CHECK(outs[0].name == "HDMI-A-1"); + CHECK(outs[1].name == "HDMI-A-2"); + CHECK(outs[2].name == "DP-1"); + + CHECK(outs[0].connected); + CHECK(!outs[1].connected); + CHECK(outs[2].connected); + + // The kernel repeats a mode per refresh rate; the same resolution twice is + // not two choices to offer the user. + REQUIRE(outs[0].modes.size() == 2); + CHECK(outs[0].modes[0] == "1920x1080"); + CHECK(outs[0].modes[1] == "1280x720"); + + CHECK(outs[1].modes.isEmpty()); +} + +TEST_CASE("A machine with no DRM at all", "[gfx]") +{ + // Windows, macOS, a Linux without DRM. With no QGuiApplication to ask + // either -- which is the case here, and is also the case at startup, before + // one exists -- there is nothing to report, and reporting nothing is right. + // The settings UI does have one, and falls back to Qt's screen list there. + CHECK(enumerateOutputs("/nonexistent/class/drm").isEmpty()); +} + +TEST_CASE("The KMS config says only what was actually set", "[gfx]") +{ + DisplaySettings s; + s.outputs.push_back(DisplayOutputSettings{.name = "HDMI-A-1", .mode = "1920x1080"}); + + const auto doc = QJsonDocument::fromJson(toKmsConfig(s)); + REQUIRE(doc.isObject()); + const auto root = doc.object(); + + // An absent key means "whatever the driver decided", which beats anything + // score could invent: writing defaults would silently override the display. + CHECK(!root.contains("device")); + CHECK(!root.contains("hwcursor")); + CHECK(!root.contains("headless")); + CHECK(!root.contains("virtualDesktopLayout")); + + REQUIRE(root["outputs"].toArray().size() == 1); + const auto o = root["outputs"].toArray()[0].toObject(); + CHECK(o["name"].toString() == "HDMI-A-1"); + CHECK(o["mode"].toString() == "1920x1080"); + CHECK(!o.contains("primary")); + CHECK(!o.contains("virtualPos")); + CHECK(!o.contains("clones")); +} + +TEST_CASE("Two outputs, placed and formatted", "[gfx]") +{ + DisplaySettings s; + s.verticalLayout = true; + s.hardwareCursor = false; + s.outputs.push_back(DisplayOutputSettings{ + .name = "HDMI-A-1", + .mode = "1920x1080@60", + .format = "argb8888", + .primary = true, + .x = 0, + .y = 0, + .hasPosition = true}); + s.outputs.push_back(DisplayOutputSettings{ + .name = "HDMI-A-2", .mode = "off", .x = 0, .y = 1080, .hasPosition = true}); + + const auto root = QJsonDocument::fromJson(toKmsConfig(s)).object(); + + CHECK(root["virtualDesktopLayout"].toString() == "vertical"); + CHECK(root["hwcursor"].toBool() == false); + + const auto arr = root["outputs"].toArray(); + REQUIRE(arr.size() == 2); + + CHECK(arr[0].toObject()["primary"].toBool()); + CHECK(arr[0].toObject()["format"].toString() == "argb8888"); + // The platform parses this itself, so the spelling matters. + CHECK(arr[1].toObject()["virtualPos"].toString() == "0, 1080"); + CHECK(arr[1].toObject()["mode"].toString() == "off"); +} + +TEST_CASE("Settings survive a round trip through the file", "[gfx]") +{ + QTemporaryDir tmp; + REQUIRE(tmp.isValid()); + const auto path = tmp.path() + "/sub/display.json"; + + DisplaySettings s; + s.headless = "1920x1080"; + s.rotation = 90; + s.hideCursor = true; + s.vulkanDisplayIndex = 1; + s.vulkanModeIndex = 3; + s.outputs.push_back(DisplayOutputSettings{ + .name = "DP-1", + .mode = "3840x2160", + .primary = true, + .x = 100, + .y = 200, + .hasPosition = true, + .physicalWidthMm = 600, + .cloneOf = "HDMI-A-1"}); + + REQUIRE(saveDisplaySettings(s, path)); + + const auto back = loadDisplaySettings(path); + CHECK(back.headless == "1920x1080"); + CHECK(back.rotation == 90); + CHECK(back.hideCursor); + CHECK(back.vulkanDisplayIndex == 1); + CHECK(back.vulkanModeIndex == 3); + // Untouched stays untouched rather than becoming 0, which would mean + // "physical device 0" to the platform. + CHECK(back.vulkanPhysicalDeviceIndex == -1); + + REQUIRE(back.outputs.size() == 1); + const auto& o = back.outputs[0]; + CHECK(o.name == "DP-1"); + CHECK(o.mode == "3840x2160"); + CHECK(o.primary); + CHECK(o.hasPosition); + CHECK(o.x == 100); + CHECK(o.y == 200); + CHECK(o.physicalWidthMm == 600); + CHECK(o.cloneOf == "HDMI-A-1"); +} + +TEST_CASE("A missing or broken file is not a configuration", "[gfx]") +{ + CHECK(loadDisplaySettings("/nonexistent/display.json").isEmpty()); + + QTemporaryDir tmp; + REQUIRE(tmp.isValid()); + const auto path = tmp.path() + "/display.json"; + QFile f{path}; + REQUIRE(f.open(QIODevice::WriteOnly)); + f.write("this is not json"); + f.close(); + + CHECK(loadDisplaySettings(path).isEmpty()); +} + +TEST_CASE("What each platform can actually be told", "[gfx]") +{ + // eglfs takes connector names and modes through its JSON. + CHECK(displayCapabilities("eglfs").perOutputConfiguration); + CHECK(!displayCapabilities("eglfs").indexedDisplaySelection); + + // vkkhrdisplay has three integers and nothing else -- no names, no layout, + // no cloning. The settings UI has to offer less rather than pretend. + CHECK(displayCapabilities("vkkhrdisplay").indexedDisplaySelection); + CHECK(!displayCapabilities("vkkhrdisplay").perOutputConfiguration); + + // Both embedded platforms are read once, at startup: nothing can be applied + // to a running process, so the UI cannot offer to undo a bad setting. + CHECK(displayCapabilities("eglfs").requiresRestart); + CHECK(displayCapabilities("vkkhrdisplay").requiresRestart); + + // Where a window manager owns the display, none of this applies -- yet. The + // desktop platforms can be told to change displays while running, so when + // that is implemented they will gain perOutputConfiguration *without* + // requiresRestart, and will want a confirmation the embedded ones cannot. + for(const auto* p : {"xcb", "wayland", "cocoa", "windows", "offscreen"}) + { + CHECK(!displayCapabilities(p).anyConfiguration()); + CHECK(!displayCapabilities(p).requiresRestart); + } + + // Windows and macOS own their displays and can be asked to rearrange them; + // a bare X11 or Wayland session cannot be, through this route. + CHECK(displayCapabilities("windows").appliesToSystemDisplays); + CHECK(displayCapabilities("cocoa").appliesToSystemDisplays); + CHECK(!displayCapabilities("xcb").appliesToSystemDisplays); +} + +TEST_CASE("The way back to an editor", "[gfx]") +{ + DisplaySettings s; + + // Nothing saved: whatever was chosen stands. + CHECK(resolvePlatform("eglfs", s) == "eglfs"); + CHECK(resolvePlatform("", s) == ""); + + s.platformOverride = "eglfs"; + + // vkkhrdisplay draws no widgets, so the way back to an editor is eglfs. + CHECK(resolvePlatform("vkkhrdisplay", s) == "eglfs"); + CHECK(resolvePlatform("eglfs", s) == "eglfs"); + + // A desktop session must not be hijacked by a file written for an appliance: + // the override only applies where score was choosing an embedded platform. + CHECK(resolvePlatform("xcb", s) == "xcb"); + CHECK(resolvePlatform("wayland", s) == "wayland"); + CHECK(resolvePlatform("windows", s) == "windows"); + CHECK(resolvePlatform("cocoa", s) == "cocoa"); + CHECK(resolvePlatform("", s) == ""); +} + +TEST_CASE("Editor visibility and platform override round-trip", "[gfx]") +{ + QTemporaryDir tmp; + REQUIRE(tmp.isValid()); + const auto path = tmp.path() + "/display.json"; + + DisplaySettings s; + s.editorUi = false; + s.platformOverride = "vkkhrdisplay"; + REQUIRE(saveDisplaySettings(s, path)); + + const auto back = loadDisplaySettings(path); + CHECK(!back.editorUi); + CHECK(back.platformOverride == "vkkhrdisplay"); + + // The default has to be "show the editor": a file that says nothing must + // never leave somebody with a machine they cannot drive. + DisplaySettings fresh; + CHECK(fresh.editorUi); + CHECK(fresh.isEmpty()); + + // ... and asking for the appliance is a real setting, not an empty one. + DisplaySettings appliance; + appliance.editorUi = false; + CHECK(!appliance.isEmpty()); +} diff --git a/tests/unit/ScreenPlacementTest.cpp b/tests/unit/ScreenPlacementTest.cpp new file mode 100644 index 0000000000..d6f44a6f06 --- /dev/null +++ b/tests/unit/ScreenPlacementTest.cpp @@ -0,0 +1,91 @@ +// Choosing which screen an output window gets. +// +// This only matters on the platforms with no window manager -- eglfs and +// friends on an embedded board -- where a screen backs exactly one window and +// asking for a second one calls qFatal inside Qt. There is no catching that, +// so the choice has to be right before the window is shown. +// +// The rule itself is arithmetic on a list and is tested as such; whether eglfs +// then honours it was checked on hardware (rk3588, two HDMI outputs) and is +// what ScreenNode's geometry-before-show relies on. + +#include + +#include + +#include +#include +#include + +#include + +#include + +TEST_CASE("A free screen is chosen for an output window", "[gfx]") +{ + score::test::run_in_gui_app([](const score::GUIApplicationContext&) { + const auto screens = QGuiApplication::screens(); + REQUIRE(!screens.isEmpty()); + + auto* first = screens[0]; + + SECTION("the screen the user picked wins when nothing holds it") + { + CHECK(score::gfx::freeScreen(first, screens, {}) == first); + } + + SECTION("no preference takes the first free one") + { + CHECK(score::gfx::freeScreen(nullptr, screens, {}) == first); + } + + SECTION("a preference that is taken falls through to another screen") + { + auto* got = score::gfx::freeScreen(first, screens, {first}); + + // With one screen there is nowhere to fall through to, and saying so is + // the point: the caller must not open the window at all. + if(screens.size() == 1) + CHECK(got == nullptr); + else + CHECK((got != nullptr && got != first)); + } + + SECTION("every screen taken means no window") + { + QSet all; + for(auto* s : screens) + all.insert(s); + + CHECK(score::gfx::freeScreen(first, screens, all) == nullptr); + CHECK(score::gfx::freeScreen(nullptr, screens, all) == nullptr); + } + + SECTION("no screens at all") + { + CHECK(score::gfx::freeScreen(nullptr, {}, {}) == nullptr); + } + + SECTION("the main window holds its screen") + { + // occupiedScreens() is what feeds `taken`: the widget UI has to count, + // otherwise an output would be placed on top of it and abort. + QWindow w; + w.setGeometry(first->geometry()); + w.show(); + + const auto taken = score::gfx::occupiedScreens(); + CHECK(taken.contains(w.screen())); + CHECK(score::gfx::freeScreen(w.screen(), screens, taken) != w.screen()); + } + }); +} + +TEST_CASE("A window manager places windows itself", "[gfx]") +{ + score::test::run_in_gui_app([](const score::GUIApplicationContext&) { + // The tests run under offscreen or a desktop platform, where screens are + // not exclusive and none of the above applies. + CHECK(!score::gfx::oneWindowPerScreen()); + }); +}