diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index ea53fa006..2eb4db898 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -11,6 +11,7 @@ add_subdirectory(test_hide_window) add_subdirectory(test_data_control_manager) add_subdirectory(test_region_watch) add_subdirectory(test_capture) +add_subdirectory(test_snap_target) add_subdirectory(test_window_appearance) add_subdirectory(test_decoration) add_subdirectory(test_super_overlay_surface) diff --git a/examples/test_snap_target/CMakeLists.txt b/examples/test_snap_target/CMakeLists.txt new file mode 100644 index 000000000..559f44f35 --- /dev/null +++ b/examples/test_snap_target/CMakeLists.txt @@ -0,0 +1,56 @@ +find_package(PkgConfig REQUIRED) +find_package(Qt6 REQUIRED COMPONENTS Core Gui WaylandClient Quick) +if(Qt6_VERSION VERSION_GREATER_EQUAL 6.10) + find_package(Qt6 REQUIRED COMPONENTS WaylandClientPrivate) +endif() +find_package(TreelandProtocols REQUIRED) +pkg_get_variable(WAYLAND_PROTOCOLS_DATADIR wayland-protocols pkgdatadir) + +set(BIN_NAME test-snap-target) + +qt_add_executable(${BIN_NAME} + main.cpp + screencapture.cpp + snapproxy.cpp +) + +qt_generate_wayland_protocol_client_sources(${BIN_NAME} + FILES + ${WAYLAND_PROTOCOLS_DATADIR}/staging/ext-foreign-toplevel-list/ext-foreign-toplevel-list-v1.xml + ${WAYLAND_PROTOCOLS_DATADIR}/staging/ext-image-capture-source/ext-image-capture-source-v1.xml + ${WAYLAND_PROTOCOLS_DATADIR}/staging/ext-image-copy-capture/ext-image-copy-capture-v1.xml +) + +qt_generate_wayland_protocol_client_sources(${BIN_NAME} + FILES + ${TREELAND_PROTOCOLS_DATA_DIR}/treeland-snap-target-unstable-v1.xml +) + +qt_generate_wayland_protocol_client_sources(${BIN_NAME} + FILES + ${WAYLAND_PROTOCOLS_DATADIR}/staging/xdg-toplevel-tag/xdg-toplevel-tag-v1.xml +) + +# xdg-toplevel-tag references xdg_toplevel_interface from xdg-shell +qt_generate_wayland_protocol_client_sources(${BIN_NAME} + FILES + ${WAYLAND_PROTOCOLS_DATADIR}/stable/xdg-shell/xdg-shell.xml +) + +qt_add_qml_module(${BIN_NAME} + URI TestSnapTarget + VERSION 1.0 + QML_FILES + Main.qml +) + +target_link_libraries(${BIN_NAME} + PRIVATE + Qt6::Core + Qt6::Gui + Qt6::WaylandClient + Qt6::WaylandClientPrivate + Qt6::Quick +) + +install(TARGETS ${BIN_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}") diff --git a/examples/test_snap_target/Main.qml b/examples/test_snap_target/Main.qml new file mode 100644 index 000000000..66046bf38 --- /dev/null +++ b/examples/test_snap_target/Main.qml @@ -0,0 +1,180 @@ +// Copyright (C) 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: Apache-2.0 OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only + +import QtQuick + +// One instance per output. `snapController` is injected as a per-window +// context property so that each window tracks its own output origin while +// sharing the process-wide snap session. +Window { + id: root + visible: true + flags: Qt.FramelessWindowHint + color: "transparent" + width: snapController.screenWidth + height: snapController.screenHeight + + // Press Escape to quit the demo + Shortcut { + sequence: "Escape" + onActivated: snapController.quit() + } + + // Frozen snapshot of this output, captured before any mask window was + // mapped. Only present when started with --background. + Image { + id: backgroundImage + anchors.fill: parent + fillMode: Image.Stretch + source: snapController.backgroundSource + visible: snapController.backgroundEnabled + } + + Rectangle { + id: snapHighlight + color: Qt.rgba(0.2, 0.6, 1.0, 0.1) + border.color: "#3399FF" + border.width: 3 + visible: snapController.snapVisible && !snapController.confirmed + x: snapController.snapX + y: snapController.snapY + width: snapController.snapWidth + height: snapController.snapHeight + radius: 4 + } + + // Frozen selection highlight after confirmation + Rectangle { + id: frozenSelection + color: Qt.rgba(0.2, 0.6, 1.0, 0.15) + border.color: "#3399FF" + border.width: 4 + visible: snapController.confirmed + x: snapController.snapX + y: snapController.snapY + width: snapController.snapWidth + height: snapController.snapHeight + radius: 4 + } + + // Full-screen click area — only active before confirmation + MouseArea { + id: clickCatcher + anchors.fill: parent + enabled: !snapController.confirmed + cursorShape: Qt.CrossCursor + onClicked: snapController.confirmSelection() + } + + // Demo toolbar (non-functional) shown after confirmation + Rectangle { + id: toolbar + z: 9 + visible: snapController.confirmed + color: "#2B2B2B" + border.color: "#555555" + border.width: 1 + radius: 6 + height: 48 + width: 260 + + // Center toolbar above the frozen selection, clamped to this output + x: snapController.confirmed + ? Math.max(8, Math.min(root.width - width - 8, + snapController.snapX + snapController.snapWidth / 2 - width / 2)) + : 0 + y: snapController.confirmed + ? (snapController.snapY - height - 8 >= 8 + ? snapController.snapY - height - 8 + : Math.min(root.height - height - 8, + snapController.snapY + snapController.snapHeight + 8)) + : 0 + + Row { + anchors.centerIn: parent + spacing: 10 + + Repeater { + model: [ + { icon: "✎", label: "Annotate" }, + { icon: "⌨", label: "Text" }, + { icon: "⏲", label: "Delay" }, + { icon: "💾", label: "Save" } + ] + + Rectangle { + width: 52 + height: 36 + color: "transparent" + radius: 4 + + Text { + anchors.top: parent.top + anchors.horizontalCenter: parent.horizontalCenter + anchors.topMargin: 2 + text: modelData.icon + font.pixelSize: 18 + color: "#FFFFFF" + } + + Text { + anchors.bottom: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottomMargin: 2 + text: modelData.label + font.pixelSize: 9 + color: "#AAAAAA" + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + // Demo only - no actual functionality + } + } + } + } + } + + // Close button on the toolbar's right edge + Rectangle { + anchors.right: parent.right + anchors.rightMargin: 6 + anchors.verticalCenter: parent.verticalCenter + width: 24 + height: 24 + radius: 12 + color: "transparent" + + Text { + anchors.centerIn: parent + text: "×" + font.pixelSize: 18 + color: "#FFFFFF" + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: snapController.quit() + } + } + } + + // Debug overlay - one per output + Text { + z: 10 + x: 10 + y: 10 + text: "confirmed=" + snapController.confirmed + + " vis=" + snapController.snapVisible + + " region=(" + snapController.snapX + "," + snapController.snapY + + " " + snapController.snapWidth + "x" + snapController.snapHeight + ")" + + " proc=" + snapController.processName + + " output=(" + snapController.screenX + "," + snapController.screenY + + " " + root.width + "x" + root.height + ")" + color: "red" + font.pixelSize: 14 + } +} diff --git a/examples/test_snap_target/main.cpp b/examples/test_snap_target/main.cpp new file mode 100644 index 000000000..4d3d6cd0c --- /dev/null +++ b/examples/test_snap_target/main.cpp @@ -0,0 +1,153 @@ +// Copyright (C) 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: Apache-2.0 OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only + +#include "screencapture.h" +#include "snapproxy.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +class BackgroundImageProvider : public QQuickImageProvider +{ +public: + BackgroundImageProvider() + : QQuickImageProvider(QQuickImageProvider::Image) + { + } + + QImage requestImage(const QString &id, QSize *size, const QSize &requestedSize) override + { + Q_UNUSED(requestedSize); + const QImage image = m_images.value(id); + if (size) + *size = image.size(); + return image; + } + + static QString idForGeometry(const QRect &geometry) + { + return QStringLiteral("%1_%2_%3_%4") + .arg(geometry.x()) + .arg(geometry.y()) + .arg(geometry.width()) + .arg(geometry.height()); + } + + void addImage(const QRect &geometry, const QImage &image) + { + m_images.insert(idForGeometry(geometry), image); + } + + bool hasImage(const QString &id) const + { + return m_images.contains(id); + } + +private: + QHash m_images; +}; + +int main(int argc, char *argv[]) +{ + QGuiApplication app(argc, argv); + + QCommandLineParser parser; + parser.setApplicationDescription("Treeland snap-target demo client"); + parser.addHelpOption(); + const QCommandLineOption backgroundOption( + QStringLiteral("background"), + QStringLiteral("Capture each output with ext-image-copy-capture before showing the " + "selection UI and use the per-output images as window backgrounds.")); + parser.addOption(backgroundOption); + parser.process(app); + + // Capture backgrounds before any mask surface is mapped, so the masks do + // not show up in their own screenshots. + QList capturedOutputs; + if (parser.isSet(backgroundOption)) { + ScreenCapture capture; + capturedOutputs = capture.captureOutputs(); + if (capturedOutputs.isEmpty()) + qWarning() << "Background capture failed, falling back to a transparent background"; + } + + // One snap session per process, shared by every per-output mask window. + SnapSession session; + + auto *backgroundProvider = new BackgroundImageProvider; + for (const auto &output : std::as_const(capturedOutputs)) + backgroundProvider->addImage(output.logicalGeometry, output.image); + + QQmlApplicationEngine engine; + engine.addImageProvider("captureBackground", backgroundProvider); + + QQmlComponent component(&engine, QUrl(QStringLiteral("qrc:/qt/qml/TestSnapTarget/Main.qml"))); + if (component.isError()) { + qWarning() << component.errors(); + return -1; + } + + const QList screens = QGuiApplication::screens(); + if (screens.isEmpty()) { + qWarning() << "No outputs available"; + return -1; + } + + struct MaskWindow + { + SnapController *controller = nullptr; + QQuickWindow *window = nullptr; + QScreen *screen = nullptr; + }; + + QList masks; + for (QScreen *screen : screens) { + auto *controller = new SnapController(&session, &session); + auto *context = new QQmlContext(engine.rootContext()); + context->setContextProperty(QStringLiteral("snapController"), controller); + + QObject *object = component.create(context); + auto *window = qobject_cast(object); + if (!window) { + qWarning() << "Failed to create mask window for output" << screen->name() + << component.errors(); + delete object; + continue; + } + + window->setScreen(screen); + + if (parser.isSet(backgroundOption)) { + const QString id = BackgroundImageProvider::idForGeometry(screen->geometry()); + if (backgroundProvider->hasImage(id)) { + controller->setBackgroundSource(QStringLiteral("image://captureBackground/") + id); + controller->setBackgroundEnabled(true); + } + } + + masks.append({ controller, window, screen }); + } + + if (masks.isEmpty()) + return -1; + + QTimer::singleShot(0, [&session, masks]() { + for (const MaskWindow &mask : masks) + mask.controller->setup(mask.window, mask.screen); + session.start(); + }); + + return app.exec(); +} diff --git a/examples/test_snap_target/screencapture.cpp b/examples/test_snap_target/screencapture.cpp new file mode 100644 index 000000000..1a033d814 --- /dev/null +++ b/examples/test_snap_target/screencapture.cpp @@ -0,0 +1,257 @@ +// Copyright (C) 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: Apache-2.0 OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only + +#include "screencapture.h" + +#include + +#include +#include + +#include +#include +#include +#include + +QImage::Format ScreenCapture::qimageFormatForShm(uint32_t format) +{ + switch (format) { + case WL_SHM_FORMAT_ARGB8888: + case WL_SHM_FORMAT_BGRA8888: + return QImage::Format_ARGB32; + case WL_SHM_FORMAT_XRGB8888: + case WL_SHM_FORMAT_BGRX8888: + return QImage::Format_RGB32; + case WL_SHM_FORMAT_XBGR8888: + return QImage::Format_RGBX8888; + default: + return QImage::Format_Invalid; + } +} + +ScreenCapture::~ScreenCapture() +{ + for (Output *output : std::as_const(m_outputs)) { + cleanupOutput(output); + if (output->xdgOutput.isInitialized()) + output->xdgOutput.destroy(); + if (output->wlOutput.isInitialized()) + output->wlOutput.release(); + delete output; + } +} + +void ScreenCapture::cleanupOutput(Output *output) +{ + if (output->frame.isInitialized()) + output->frame.destroy(); + if (output->buffer.isInitialized()) + output->buffer.destroy(); + if (output->shmPool.isInitialized()) + output->shmPool.destroy(); + if (output->shmData) { + munmap(output->shmData, output->bufferH * output->bufferW * 4); + output->shmData = nullptr; + } + if (output->shmFd >= 0) { + close(output->shmFd); + output->shmFd = -1; + } + if (output->session.isInitialized()) + output->session.destroy(); +} + +void ScreenCapture::Registry::registry_global(uint32_t name, + const QString &interface, + uint32_t version) +{ + auto *self = owner; + if (interface == QLatin1String("wl_shm")) { + self->m_shm.init(self->m_registry.object(), name, version); + } else if (interface == QLatin1String("zxdg_output_manager_v1")) { + self->m_xdgOutputManager.init(self->m_registry.object(), name, version); + } else if (interface == QLatin1String("ext_output_image_capture_source_manager_v1")) { + self->m_extSourceManager.init(self->m_registry.object(), name, version); + } else if (interface == QLatin1String("ext_image_copy_capture_manager_v1")) { + self->m_extCopyManager.init(self->m_registry.object(), name, version); + } else if (interface == QLatin1String("wl_output")) { + auto *output = new Output; + output->wlOutput.init(self->m_registry.object(), name, version); + self->m_outputs.append(output); + } +} + +void ScreenCapture::Registry::registry_global_remove(uint32_t name) +{ + Q_UNUSED(name); +} + +void ScreenCapture::XdgOutput::zxdg_output_v1_logical_position(int32_t x, int32_t y) +{ + output->logicalX = x; + output->logicalY = y; +} + +void ScreenCapture::XdgOutput::zxdg_output_v1_logical_size(int32_t width, int32_t height) +{ + output->logicalW = width; + output->logicalH = height; +} + +void ScreenCapture::Session::ext_image_copy_capture_session_v1_buffer_size(uint32_t width, + uint32_t height) +{ + output->bufferW = width; + output->bufferH = height; +} + +void ScreenCapture::Session::ext_image_copy_capture_session_v1_shm_format(uint32_t format) +{ + if (output->imageFormat != QImage::Format_Invalid) + return; + output->imageFormat = qimageFormatForShm(format); + if (output->imageFormat != QImage::Format_Invalid) + output->shmFormat = format; +} + +void ScreenCapture::Session::ext_image_copy_capture_session_v1_done() +{ + output->constraintsDone = true; +} + +void ScreenCapture::Frame::ext_image_copy_capture_frame_v1_ready() +{ + auto *output = this->output; + if (!output->shmData || output->bufferW <= 0 || output->bufferH <= 0 + || output->imageFormat == QImage::Format_Invalid) { + output->captureFailed = true; + } else { + QImage image(static_cast(output->shmData), + output->bufferW, + output->bufferH, + output->bufferW * 4, + output->imageFormat); + output->image = image.copy(); + output->captureReady = true; + } + destroy(); +} + +void ScreenCapture::Frame::ext_image_copy_capture_frame_v1_failed(uint32_t reason) +{ + Q_UNUSED(reason); + output->captureFailed = true; + destroy(); +} + +QList ScreenCapture::captureOutputs() +{ + m_outputs.clear(); + + auto *app = qGuiApp; + auto *waylandApp = + app ? app->nativeInterface() : nullptr; + if (!waylandApp) + return { }; + m_display = waylandApp->display(); + if (!m_display) + return { }; + + m_registry.owner = this; + m_registry.init(wl_display_get_registry(m_display)); + wl_display_roundtrip(m_display); + + if (!m_shm.isInitialized() || !m_extSourceManager.isInitialized() + || !m_extCopyManager.isInitialized() || m_outputs.isEmpty()) { + return { }; + } + + if (m_xdgOutputManager.isInitialized()) { + for (Output *output : std::as_const(m_outputs)) { + output->xdgOutput.output = output; + output->xdgOutput.init(m_xdgOutputManager.get_xdg_output(output->wlOutput.object())); + } + wl_display_roundtrip(m_display); + } + + for (Output *output : std::as_const(m_outputs)) { + output->session.output = output; + auto *source = m_extSourceManager.create_source(output->wlOutput.object()); + auto *session = m_extCopyManager.create_session(source, 0); + ext_image_capture_source_v1_destroy(source); + output->session.init(session); + } + wl_display_roundtrip(m_display); + + static int serial = 0; + int framesSent = 0; + QVector pendingOutputs; + for (Output *output : std::as_const(m_outputs)) { + if (!output->constraintsDone || output->imageFormat == QImage::Format_Invalid) + continue; + + const int stride = output->bufferW * 4; + const int size = stride * output->bufferH; + + QByteArray name = QByteArrayLiteral("/treeland-snap-target-") + + QByteArray::number(static_cast(getpid())) + QByteArrayLiteral("-") + + QByteArray::number(serial++); + int fd = shm_open(name.constData(), O_RDWR | O_CREAT | O_EXCL | O_CLOEXEC, 0600); + if (fd < 0) + continue; + shm_unlink(name.constData()); + if (ftruncate(fd, size) < 0) { + close(fd); + continue; + } + auto *data = mmap(nullptr, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); + if (data == MAP_FAILED) { + close(fd); + continue; + } + + output->shmFd = fd; + output->shmData = data; + output->shmPool.init(m_shm.create_pool(fd, size)); + output->buffer.init(output->shmPool.create_buffer(0, + output->bufferW, + output->bufferH, + stride, + output->shmFormat)); + + output->frame.output = output; + auto *frame = output->session.create_frame(); + output->frame.init(frame); + output->frame.attach_buffer(output->buffer.object()); + output->frame.damage_buffer(0, 0, INT32_MAX, INT32_MAX); + output->frame.capture(); + ++framesSent; + pendingOutputs.append(output); + } + + if (framesSent > 0) { + bool pending = true; + while (pending) { + if (wl_display_dispatch(m_display) < 0) + break; + pending = false; + for (Output *output : std::as_const(pendingOutputs)) { + if (!output->captureReady && !output->captureFailed) { + pending = true; + break; + } + } + } + } + + QList result; + for (Output *output : std::as_const(m_outputs)) { + if (!output->captureReady || output->image.isNull()) + continue; + const int w = output->logicalW > 0 ? output->logicalW : output->bufferW; + const int h = output->logicalH > 0 ? output->logicalH : output->bufferH; + result.append({ QRect(output->logicalX, output->logicalY, w, h), output->image }); + } + + return result; +} diff --git a/examples/test_snap_target/screencapture.h b/examples/test_snap_target/screencapture.h new file mode 100644 index 000000000..7f09b53f1 --- /dev/null +++ b/examples/test_snap_target/screencapture.h @@ -0,0 +1,120 @@ +// Copyright (C) 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: Apache-2.0 OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only + +#ifndef SCREENCAPTURE_H +#define SCREENCAPTURE_H + +#include "qwayland-ext-image-capture-source-v1.h" +#include "qwayland-ext-image-copy-capture-v1.h" + +#include +#include +#include +#include +#include +#include + +class ScreenCapture +{ +public: + ScreenCapture() = default; + ~ScreenCapture(); + + // One captured output: its logical geometry (xdg-output coordinates) and + // the raw captured image (buffer pixel size). + struct CapturedOutput + { + QRect logicalGeometry; + QImage image; + }; + + // Captures every output separately via ext-image-copy-capture. Returns an + // empty list on failure. + QList captureOutputs(); + +private: + struct Output; + + class Registry : public QtWayland::wl_registry + { + public: + ScreenCapture *owner = nullptr; + + protected: + void registry_global(uint32_t name, const QString &interface, uint32_t version) override; + void registry_global_remove(uint32_t name) override; + }; + + class XdgOutput : public QtWayland::zxdg_output_v1 + { + public: + Output *output = nullptr; + + protected: + void zxdg_output_v1_logical_position(int32_t x, int32_t y) override; + void zxdg_output_v1_logical_size(int32_t width, int32_t height) override; + }; + + class Session : public QtWayland::ext_image_copy_capture_session_v1 + { + public: + Output *output = nullptr; + + protected: + void ext_image_copy_capture_session_v1_buffer_size(uint32_t width, + uint32_t height) override; + void ext_image_copy_capture_session_v1_shm_format(uint32_t format) override; + void ext_image_copy_capture_session_v1_done() override; + }; + + class Frame : public QtWayland::ext_image_copy_capture_frame_v1 + { + public: + Output *output = nullptr; + + protected: + void ext_image_copy_capture_frame_v1_ready() override; + void ext_image_copy_capture_frame_v1_failed(uint32_t reason) override; + }; + + struct Output + { + QtWayland::wl_output wlOutput; + XdgOutput xdgOutput; + int logicalX = 0; + int logicalY = 0; + int logicalW = 0; + int logicalH = 0; + + Session session; + Frame frame; + QtWayland::wl_shm_pool shmPool; + QtWayland::wl_buffer buffer; + + int bufferW = 0; + int bufferH = 0; + uint32_t shmFormat = 0; + QImage::Format imageFormat = QImage::Format_Invalid; + bool constraintsDone = false; + + int shmFd = -1; + void *shmData = nullptr; + + QImage image; + bool captureReady = false; + bool captureFailed = false; + }; + + static QImage::Format qimageFormatForShm(uint32_t format); + void cleanupOutput(Output *output); + + Registry m_registry; + QtWayland::wl_shm m_shm; + QtWayland::zxdg_output_manager_v1 m_xdgOutputManager; + QtWayland::ext_output_image_capture_source_manager_v1 m_extSourceManager; + QtWayland::ext_image_copy_capture_manager_v1 m_extCopyManager; + QVector m_outputs; + wl_display *m_display = nullptr; +}; + +#endif // SCREENCAPTURE_H diff --git a/examples/test_snap_target/snapproxy.cpp b/examples/test_snap_target/snapproxy.cpp new file mode 100644 index 000000000..36c5a4bd3 --- /dev/null +++ b/examples/test_snap_target/snapproxy.cpp @@ -0,0 +1,412 @@ +// Copyright (C) 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: Apache-2.0 OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only + +#include "snapproxy.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace { + +constexpr auto SnapMaskTag = "org.deepin.treeland.snap-mask"; + +QString processNameForPidfd(int pidfd) +{ + QFile fdinfo(QStringLiteral("/proc/self/fdinfo/%1").arg(pidfd)); + if (!fdinfo.open(QIODevice::ReadOnly)) + return { }; + + qint64 pid = 0; + const QByteArray content = fdinfo.readAll(); + const QList lines = content.split('\n'); + for (const QByteArray &line : lines) { + if (line.startsWith("Pid:")) { + pid = line.mid(4).trimmed().toLongLong(); + break; + } + } + if (pid <= 0) + return { }; + + QFile comm(QStringLiteral("/proc/%1/comm").arg(pid)); + if (!comm.open(QIODevice::ReadOnly)) + return { }; + + return QString::fromLocal8Bit(comm.readAll()).trimmed(); +} + +void seatRegistryGlobal(void *data, + wl_registry *registry, + uint32_t name, + const char *interface, + uint32_t version) +{ + auto **seat = static_cast(data); + if (*seat || qstrcmp(interface, wl_seat_interface.name) != 0) + return; + *seat = static_cast( + wl_registry_bind(registry, name, &wl_seat_interface, qMin(version, 9))); +} + +void seatRegistryGlobalRemove(void *data, wl_registry *registry, uint32_t name) +{ + Q_UNUSED(data); + Q_UNUSED(registry); + Q_UNUSED(name); +} + +const wl_registry_listener seatRegistryListener = { + seatRegistryGlobal, + seatRegistryGlobalRemove, +}; + +} // namespace + +ToplevelTagManager::ToplevelTagManager() + : QWaylandClientExtensionTemplate(1) +{ +} + +ToplevelTagManager::~ToplevelTagManager() +{ + if (isActive()) + destroy(); +} + +Snap::Snap() + : QWaylandClientExtensionTemplate(1) +{ +} + +Snap::~Snap() +{ + if (isActive()) + destroy(); +} + +void Snap::treeland_snap_target_v1_snap_region(int32_t x, + int32_t y, + uint32_t width, + uint32_t height) +{ + emit snapRegion(static_cast(x), + static_cast(y), + static_cast(width), + static_cast(height)); +} + +void Snap::treeland_snap_target_v1_pidfd(int32_t pidfd) +{ + emit this->pidfd(static_cast(pidfd)); +} + +void Snap::treeland_snap_target_v1_failed(uint32_t reason) +{ + emit failed(static_cast(reason)); +} + +struct ::xdg_toplevel *getXdgToplevel(QWindow *window) +{ + if (!window || !window->handle()) + return nullptr; + + auto *ww = static_cast(window->handle()); + if (!ww) + return nullptr; + + auto *ss = ww->shellSurface(); + if (!ss) + return nullptr; + + std::any role = ss->surfaceRole(); + try { + return std::any_cast(role); + } catch (const std::bad_any_cast &) { + } + return nullptr; +} + +SnapSession::SnapSession(QObject *parent) + : QObject(parent) +{ + if (auto *waylandApp = + qGuiApp ? qGuiApp->nativeInterface() : nullptr) { + if (auto *display = waylandApp->display()) { + m_seatRegistry = wl_display_get_registry(display); + wl_registry_add_listener(m_seatRegistry, &seatRegistryListener, &m_seat); + wl_display_roundtrip(display); + } + } + + connect(&m_tagManager, + &QWaylandClientExtension::activeChanged, + this, + &SnapSession::maybeInitialize); + connect(&m_snap, &QWaylandClientExtension::activeChanged, this, &SnapSession::maybeInitialize); +} + +SnapSession::~SnapSession() +{ + clearPendingPidfd(); + if (m_seat) { + wl_seat_destroy(m_seat); + m_seat = nullptr; + } + if (m_seatRegistry) { + wl_registry_destroy(m_seatRegistry); + m_seatRegistry = nullptr; + } +} + +void SnapSession::registerWindow(QWindow *window) +{ + if (!window || m_windows.contains(window)) + return; + m_windows.append(window); + if (m_tagManager.isActive()) + tagWindow(window); +} + +void SnapSession::start() +{ + m_startRequested = true; + if (!m_initialized || m_started) + return; + m_started = true; + m_snap.start(m_seat, TREELAND_SNAP_TARGET_V1_EVENT_TYPE_PIDFD); +} + +void SnapSession::maybeInitialize() +{ + if (m_initialized) + return; + if (!m_tagManager.isActive() || !m_snap.isActive()) + return; + + m_initialized = true; + + for (const auto &window : std::as_const(m_windows)) { + if (window) + tagWindow(window); + } + + connect(&m_snap, &Snap::snapRegion, this, &SnapSession::onSnapRegion); + connect(&m_snap, &Snap::pidfd, this, &SnapSession::onPidfd); + connect(&m_snap, &Snap::failed, this, &SnapSession::onFailed); + + if (m_startRequested) + start(); +} + +void SnapSession::tagWindow(QWindow *window) +{ + auto *toplevel = getXdgToplevel(window); + if (!toplevel) { + qWarning() << "Could not obtain xdg_toplevel from window surface role"; + return; + } + m_tagManager.set_toplevel_tag(toplevel, SnapMaskTag); +} + +void SnapSession::onPidfd(int pidfd) +{ + clearPendingPidfd(); + if (pidfd < 0) + return; + + // The event fd is owned by this client and remains valid until we close + // it, so keep it directly until the committing snap_region event arrives. + m_pendingPidfd = pidfd; +} + +void SnapSession::onSnapRegion(int x, int y, quint32 width, quint32 height) +{ + // snap_region commits the target: consume the pidfd received before it. + if (m_pendingPidfd >= 0) + setProcessName(processNameForPidfd(m_pendingPidfd)); + else + setProcessName(QString()); + clearPendingPidfd(); + + if (width != 0 && height != 0) + m_region = QRect(x, y, static_cast(width), static_cast(height)); + else + m_region = QRect(); + + Q_EMIT regionChanged(); +} + +void SnapSession::onFailed(quint32 reason) +{ + const char *reasonStr = "unknown"; + switch (reason) { + case QtWayland::treeland_snap_target_v1::failure_reason_snap_busy: + reasonStr = "snap_busy"; + break; + } + qWarning() << "Snap failed:" << reasonStr; + QCoreApplication::quit(); +} + +void SnapSession::confirmSelection() +{ + if (m_confirmed) + return; + m_confirmed = true; + Q_EMIT confirmedChanged(); + + m_snap.stop(); +} + +void SnapSession::quit() +{ + QCoreApplication::quit(); +} + +void SnapSession::setProcessName(const QString &name) +{ + if (m_processName == name) + return; + m_processName = name; + Q_EMIT processNameChanged(); +} + +void SnapSession::clearPendingPidfd() +{ + if (m_pendingPidfd < 0) + return; + ::close(m_pendingPidfd); + m_pendingPidfd = -1; +} + +SnapController::SnapController(SnapSession *session, QObject *parent) + : QObject(parent) + , m_session(session) +{ + connect(m_session, &SnapSession::regionChanged, this, &SnapController::onSessionRegionChanged); + connect(m_session, + &SnapSession::processNameChanged, + this, + &SnapController::onSessionProcessNameChanged); + connect(m_session, + &SnapSession::confirmedChanged, + this, + &SnapController::onSessionConfirmedChanged); +} + +SnapController::~SnapController() = default; + +void SnapController::setup(QWindow *window, QScreen *screen) +{ + m_window = window; + m_screen = screen; + + if (m_window) { + if (m_screen) { + m_window->setScreen(m_screen); + m_window->setVisibility(QWindow::FullScreen); + } + m_session->registerWindow(m_window); + } + + Q_EMIT screenGeometryChanged(); +} + +void SnapController::confirmSelection() +{ + m_session->confirmSelection(); +} + +void SnapController::quit() +{ + m_session->quit(); +} + +QString SnapController::processName() const +{ + return m_session->processName(); +} + +bool SnapController::confirmed() const +{ + return m_session->confirmed(); +} + +void SnapController::setBackgroundEnabled(bool enabled) +{ + if (m_backgroundEnabled == enabled) + return; + m_backgroundEnabled = enabled; + Q_EMIT backgroundEnabledChanged(); +} + +void SnapController::setBackgroundSource(const QString &source) +{ + if (m_backgroundSource == source) + return; + m_backgroundSource = source; + Q_EMIT backgroundSourceChanged(); +} + +int SnapController::screenX() const +{ + return m_screen ? m_screen->geometry().x() : 0; +} + +int SnapController::screenY() const +{ + return m_screen ? m_screen->geometry().y() : 0; +} + +int SnapController::screenWidth() const +{ + return m_screen ? m_screen->geometry().width() : 0; +} + +int SnapController::screenHeight() const +{ + return m_screen ? m_screen->geometry().height() : 0; +} + +void SnapController::onSessionRegionChanged() +{ + const QRect global = m_session->region(); + const QPoint origin = m_screen ? m_screen->geometry().topLeft() : QPoint(0, 0); + + // Only remember the geometry of non-empty snap targets so that the + // frozen selection / toolbar after confirmation still reflects the last + // valid region the user hovered over. + if (global.isValid() && !global.isEmpty()) { + m_x = global.x() - origin.x(); + m_y = global.y() - origin.y(); + m_w = global.width(); + m_h = global.height(); + } + m_visible = global.isValid() && !global.isEmpty(); + + Q_EMIT snapRegionChanged(); +} + +void SnapController::onSessionProcessNameChanged() +{ + Q_EMIT processNameChanged(); +} + +void SnapController::onSessionConfirmedChanged() +{ + Q_EMIT confirmedChanged(); +} diff --git a/examples/test_snap_target/snapproxy.h b/examples/test_snap_target/snapproxy.h new file mode 100644 index 000000000..23bba975f --- /dev/null +++ b/examples/test_snap_target/snapproxy.h @@ -0,0 +1,219 @@ +// Copyright (C) 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: Apache-2.0 OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only + +#ifndef SNAPPROXY_H +#define SNAPPROXY_H + +#include "qwayland-treeland-snap-target-unstable-v1.h" +#include "qwayland-xdg-shell.h" +#include "qwayland-xdg-toplevel-tag-v1.h" + +#include +#include +#include +#include +#include +#include + +class QScreen; +class QWindow; + +struct wl_registry; +struct wl_seat; + +class ToplevelTagManager + : public QWaylandClientExtensionTemplate + , public QtWayland::xdg_toplevel_tag_manager_v1 +{ + Q_OBJECT +public: + ToplevelTagManager(); + ~ToplevelTagManager() override; +}; + +class Snap + : public QWaylandClientExtensionTemplate + , public QtWayland::treeland_snap_target_v1 +{ + Q_OBJECT +public: + Snap(); + ~Snap() override; + +signals: + void snapRegion(int x, int y, quint32 width, quint32 height); + void pidfd(int pidfd); + void failed(quint32 reason); + +protected: + void treeland_snap_target_v1_snap_region(int32_t x, + int32_t y, + uint32_t width, + uint32_t height) override; + void treeland_snap_target_v1_pidfd(int32_t pidfd) override; + void treeland_snap_target_v1_failed(uint32_t reason) override; +}; + +struct ::xdg_toplevel *getXdgToplevel(QWindow *window); + +// Process-wide snap session. There is a single treeland_snap_target_v1 object +// per client, shared by every per-output mask window. +class SnapSession : public QObject +{ + Q_OBJECT +public: + explicit SnapSession(QObject *parent = nullptr); + ~SnapSession() override; + + void registerWindow(QWindow *window); + void start(); + + QRect region() const + { + return m_region; + } + + QString processName() const + { + return m_processName; + } + + bool confirmed() const + { + return m_confirmed; + } + + void confirmSelection(); + void quit(); + +signals: + void regionChanged(); + void processNameChanged(); + void confirmedChanged(); + +private: + void maybeInitialize(); + void tagWindow(QWindow *window); + void onSnapRegion(int x, int y, quint32 width, quint32 height); + void onPidfd(int pidfd); + void onFailed(quint32 reason); + void setProcessName(const QString &name); + void clearPendingPidfd(); + + ToplevelTagManager m_tagManager; + Snap m_snap; + + wl_registry *m_seatRegistry = nullptr; + wl_seat *m_seat = nullptr; + + QList> m_windows; + bool m_initialized = false; + bool m_startRequested = false; + bool m_started = false; + bool m_confirmed = false; + QRect m_region; + QString m_processName; + int m_pendingPidfd = -1; +}; + +// Per-output mask window controller, exposed to QML as `snapController`. +class SnapController : public QObject +{ + Q_OBJECT + Q_PROPERTY(int snapX READ snapX NOTIFY snapRegionChanged) + Q_PROPERTY(int snapY READ snapY NOTIFY snapRegionChanged) + Q_PROPERTY(int snapWidth READ snapWidth NOTIFY snapRegionChanged) + Q_PROPERTY(int snapHeight READ snapHeight NOTIFY snapRegionChanged) + Q_PROPERTY(bool snapVisible READ snapVisible NOTIFY snapRegionChanged) + Q_PROPERTY(QString processName READ processName NOTIFY processNameChanged) + Q_PROPERTY(bool confirmed READ confirmed NOTIFY confirmedChanged) + Q_PROPERTY(bool backgroundEnabled READ backgroundEnabled WRITE setBackgroundEnabled + NOTIFY backgroundEnabledChanged) + Q_PROPERTY(QString backgroundSource READ backgroundSource WRITE setBackgroundSource + NOTIFY backgroundSourceChanged) + Q_PROPERTY(int screenX READ screenX NOTIFY screenGeometryChanged) + Q_PROPERTY(int screenY READ screenY NOTIFY screenGeometryChanged) + Q_PROPERTY(int screenWidth READ screenWidth NOTIFY screenGeometryChanged) + Q_PROPERTY(int screenHeight READ screenHeight NOTIFY screenGeometryChanged) + +public: + explicit SnapController(SnapSession *session, QObject *parent = nullptr); + ~SnapController() override; + + void setup(QWindow *window, QScreen *screen); + Q_INVOKABLE void confirmSelection(); + Q_INVOKABLE void quit(); + + int snapX() const + { + return m_x; + } + + int snapY() const + { + return m_y; + } + + int snapWidth() const + { + return m_w; + } + + int snapHeight() const + { + return m_h; + } + + bool snapVisible() const + { + return m_visible; + } + + QString processName() const; + bool confirmed() const; + + bool backgroundEnabled() const + { + return m_backgroundEnabled; + } + + void setBackgroundEnabled(bool enabled); + + QString backgroundSource() const + { + return m_backgroundSource; + } + + void setBackgroundSource(const QString &source); + int screenX() const; + int screenY() const; + int screenWidth() const; + int screenHeight() const; + +signals: + void snapRegionChanged(); + void processNameChanged(); + void confirmedChanged(); + void backgroundEnabledChanged(); + void backgroundSourceChanged(); + void screenGeometryChanged(); + +private: + void onSessionRegionChanged(); + void onSessionProcessNameChanged(); + void onSessionConfirmedChanged(); + + SnapSession *m_session = nullptr; + QPointer m_window; + QPointer m_screen; + + int m_x = 0; + int m_y = 0; + int m_w = 0; + int m_h = 0; + bool m_visible = false; + bool m_backgroundEnabled = false; + QString m_backgroundSource; +}; + +#endif // SNAPPROXY_H diff --git a/src/core/rootsurfacecontainer.cpp b/src/core/rootsurfacecontainer.cpp index d0936ed05..61851d025 100644 --- a/src/core/rootsurfacecontainer.cpp +++ b/src/core/rootsurfacecontainer.cpp @@ -241,6 +241,9 @@ void RootSurfaceContainer::startMove(SurfaceWrapper *surface) void RootSurfaceContainer::startResize(SurfaceWrapper *surface, Qt::Edges edges) { Q_ASSERT(edges != 0); + if (!surface || !surface->isResizable()) + return; + beginMoveResizeForSeat(nullptr, surface, edges); surface->shellSurface()->setResizeing(true); diff --git a/src/core/rootsurfacecontainer.h b/src/core/rootsurfacecontainer.h index 8048c6e8c..7091e73ed 100644 --- a/src/core/rootsurfacecontainer.h +++ b/src/core/rootsurfacecontainer.h @@ -59,6 +59,7 @@ class RootSurfaceContainer : public SurfaceContainer CaptureLayerZOrder = 6, LockScreenZOrder = 7, GlobalOverlayZOrder = 100, + SnapMaskLayerZOrder = 180, PrivilegedOverlayZOrder = 200, // Privileged overlay, above lock screen & lock screen popups }; diff --git a/src/core/shellhandler.cpp b/src/core/shellhandler.cpp index 06682120a..5114af200 100644 --- a/src/core/shellhandler.cpp +++ b/src/core/shellhandler.cpp @@ -9,6 +9,7 @@ #include "core/windowconfigstore.h" #include "layersurfacecontainer.h" #include "modules/app-id-resolver/appidresolver.h" +#include "modules/snap-target/snaphandler.h" #include "modules/dde-shell/ddeshellmanagerinterfacev1.h" #include "modules/foreign-toplevel/foreigntoplevelmanagerv2.h" #include "modules/layer-shell-extension/layershellextensionmanagerinterfacev1.h" @@ -60,6 +61,7 @@ WAYLIB_SERVER_USE_NAMESPACE #define TREELAND_XDG_SHELL_VERSION 5 const QLatin1String PRIVILEGED_OVERLAY_TAG("org.deepin.treeland.privileged-overlay"); +const QLatin1String SNAP_MASK_TAG("org.deepin.treeland.snap-mask"); ShellHandler::ShellHandler(RootSurfaceContainer *rootContainer, WServer *server) : m_rootSurfaceContainer(rootContainer) @@ -70,6 +72,7 @@ ShellHandler::ShellHandler(RootSurfaceContainer *rootContainer, WServer *server) , m_overlayContainer(new LayerSurfaceContainer(rootContainer)) , m_popupContainer(new SurfaceContainer(rootContainer)) , m_privilegedOverlayContainer(new SurfaceContainer(rootContainer)) + , m_snapMaskContainer(new SurfaceContainer(rootContainer)) , m_windowConfigStore(new WindowConfigStore(this)) { m_treelandForeignToplevel = server->attach(); @@ -96,6 +99,8 @@ ShellHandler::ShellHandler(RootSurfaceContainer *rootContainer, WServer *server) m_popupContainer->setObjectName(QStringLiteral("PopupContainer")); m_privilegedOverlayContainer->setZ(RootSurfaceContainer::PrivilegedOverlayZOrder); m_privilegedOverlayContainer->setObjectName(QStringLiteral("PrivilegedOverlayContainer")); + m_snapMaskContainer->setZ(RootSurfaceContainer::SnapMaskLayerZOrder); + m_snapMaskContainer->setObjectName(QStringLiteral("SnapMaskContainer")); connect(m_rootSurfaceContainer->outputModel(), &QAbstractItemModel::rowsInserted, this, @@ -408,6 +413,11 @@ SurfaceContainer *ShellHandler::popupContainer() const return m_popupContainer; } +SurfaceContainer *ShellHandler::snapMaskContainer() const +{ + return m_snapMaskContainer; +} + RootSurfaceContainer *ShellHandler::rootSurfaceContainer() const { return m_rootSurfaceContainer; @@ -461,6 +471,10 @@ void ShellHandler::init(WServer *server, WSeat *seat) m_wineWindowStateManager = server->attach(); m_wineWindowManager = server->attach(); + m_snapTarget = server->attach(); + m_snapTarget->setOutputRenderWindow( + qobject_cast(m_rootSurfaceContainer->window())); + m_xdgShell = server->attach(TREELAND_XDG_SHELL_VERSION); connect(m_xdgShell, &WXdgShell::toplevelSurfaceAdded, @@ -663,15 +677,20 @@ void ShellHandler::ensureXdgWrapper(WXdgToplevelSurface *surface, const QString } Q_EMIT surfaceWrapperAdded(wrapper); + // Privileged overlay, IM candidate panel, and snap mask detection via xdg-toplevel-tag QPointer wrapperPtr(wrapper); - QObject::connect(surface, &WXdgToplevelSurface::tagChanged, this, [this, wrapperPtr]() { - if (wrapperPtr) { - if (checkAndApplyPrivilegedOverlay(wrapperPtr)) - return; - if (m_imCandidatePanelManager->checkAndApplyIMCandidatePanel(wrapperPtr)) - return; - } - }); + auto applyIfTaggedSurface = [this, wrapperPtr]() { + if (!wrapperPtr) + return; + if (checkAndApplyPrivilegedOverlay(wrapperPtr)) + return; + if (m_imCandidatePanelManager->checkAndApplyIMCandidatePanel(wrapperPtr)) + return; + if (checkAndApplySnapMask(wrapperPtr)) + return; + }; + QObject::connect(surface, &WXdgToplevelSurface::tagChanged, this, applyIfTaggedSurface); + applyIfTaggedSurface(); } bool ShellHandler::checkAndApplyPrivilegedOverlay(SurfaceWrapper *wrapper) @@ -1019,6 +1038,28 @@ void ShellHandler::registerSurfaceToForeignToplevel(SurfaceWrapper *wrapper) }); } +bool ShellHandler::checkAndApplySnapMask(SurfaceWrapper *wrapper) +{ + auto *xdgSurface = qobject_cast(wrapper->shellSurface()); + if (!xdgSurface || xdgSurface->tag() != SNAP_MASK_TAG) + return false; + if (wrapper->isSnapMask()) + return true; + + wrapper->setSkipSwitcher(true); + wrapper->setSkipDockPreView(true); + wrapper->setSkipMutiTaskView(true); + wrapper->disableWindowAnimation(); + + if (auto *oldContainer = wrapper->container()) + oldContainer->removeSurface(wrapper); + m_snapMaskContainer->addSurface(wrapper); + + wrapper->setHasInitializeContainer(true); + wrapper->setSnapMask(true); + return true; +} + void ShellHandler::setupDockPreview() { Q_ASSERT(m_dockPreview); diff --git a/src/core/shellhandler.h b/src/core/shellhandler.h index 96abac4d8..7230e7499 100644 --- a/src/core/shellhandler.h +++ b/src/core/shellhandler.h @@ -35,6 +35,7 @@ class ForeignToplevelManagerInterfaceV2; class PrelaunchSplash; class WineWindowStateManager; class WineWindowManager; +class SnapTargetV1; WAYLIB_SERVER_BEGIN_NAMESPACE class WServer; @@ -74,6 +75,7 @@ class ShellHandler : public QObject [[nodiscard]] Workspace *workspace() const; [[nodiscard]] SurfaceContainer *popupContainer() const; [[nodiscard]] SurfaceContainer *privilegedOverlayContainer() const; + [[nodiscard]] SurfaceContainer *snapMaskContainer() const; [[nodiscard]] RootSurfaceContainer *rootSurfaceContainer() const; [[nodiscard]] ForeignToplevelManagerInterfaceV2 *foreignToplevel() const; @@ -135,6 +137,7 @@ private Q_SLOTS: void setupSurfaceWindowMenu(SurfaceWrapper *wrapper); void updateLayerSurfaceContainer(SurfaceWrapper *surface); void registerSurfaceToForeignToplevel(SurfaceWrapper *wrapper); + bool checkAndApplySnapMask(SurfaceWrapper *wrapper); void handleDdeShellSurfaceAdded(WAYLIB_SERVER_NAMESPACE::WSurface *surface, SurfaceWrapper *wrapper); void updateXWaylandDesktopProperties(); @@ -191,6 +194,7 @@ private Q_SLOTS: LayerSurfaceContainer *m_overlayContainer = nullptr; SurfaceContainer *m_popupContainer = nullptr; SurfaceContainer *m_privilegedOverlayContainer = nullptr; + SurfaceContainer *m_snapMaskContainer = nullptr; IMCandidatePanelManager *m_imCandidatePanelManager = nullptr; QObject *m_windowMenu = nullptr; // Prelaunch wrappers created before binding to a real shell surface @@ -208,4 +212,5 @@ private Q_SLOTS: AppIdResolverManager *m_appIdResolverManager = nullptr; LayerShellExtensionManagerInterfaceV1 *m_layerShellExtensionManagerInterfaceV1 = nullptr; WindowConfigStore *m_windowConfigStore = nullptr; + SnapTargetV1 *m_snapTarget = nullptr; }; diff --git a/src/modules/CMakeLists.txt b/src/modules/CMakeLists.txt index 072634a5a..329b3b4e8 100644 --- a/src/modules/CMakeLists.txt +++ b/src/modules/CMakeLists.txt @@ -39,6 +39,7 @@ add_subdirectory(dde-shell) add_subdirectory(active-notify) add_subdirectory(region-watch) add_subdirectory(capture) +add_subdirectory(snap-target) add_subdirectory(item-selector) add_subdirectory(ddm) add_subdirectory(screensaver) diff --git a/src/modules/snap-target/CMakeLists.txt b/src/modules/snap-target/CMakeLists.txt new file mode 100644 index 000000000..aeea45a49 --- /dev/null +++ b/src/modules/snap-target/CMakeLists.txt @@ -0,0 +1,19 @@ +find_package(TreelandProtocols REQUIRED) +if(Qt6_VERSION VERSION_GREATER_EQUAL 6.10) + find_package(Qt6 REQUIRED COMPONENTS QuickPrivate) +endif() + +waylib_generate_qtwayland_server_protocol(libtreeland + PROTOCOL ${TREELAND_PROTOCOLS_DATA_DIR}/treeland-snap-target-unstable-v1.xml + BASENAME treeland-snap-target-unstable-v1 +) + +impl_treeland( + NAME + module_snap_target + SOURCE + ${CMAKE_SOURCE_DIR}/src/modules/snap-target/snaphandler.h + ${CMAKE_SOURCE_DIR}/src/modules/snap-target/snaphandler.cpp + INCLUDE + $ +) diff --git a/src/modules/snap-target/snaphandler.cpp b/src/modules/snap-target/snaphandler.cpp new file mode 100644 index 000000000..46c1fbfdd --- /dev/null +++ b/src/modules/snap-target/snaphandler.cpp @@ -0,0 +1,317 @@ +// Copyright (C) 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: Apache-2.0 OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only + +#include "snaphandler.h" + +#include "qwayland-server-treeland-snap-target-unstable-v1.h" +#include "surface/surfacewrapper.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include + +WAYLIB_SERVER_USE_NAMESPACE + +namespace { +struct SnappableTarget +{ + QRectF rect; + QPointer shellSurface; + + bool operator==(const SnappableTarget &other) const + { + return rect == other.rect && shellSurface == other.shellSurface; + } +}; + +SurfaceWrapper *snapMaskWrapperFor(QQuickItem *item) +{ + for (QQuickItem *parent = item; parent; parent = parent->parentItem()) { + if (auto *wrapper = qobject_cast(parent)) + return wrapper; + } + return nullptr; +} + +QList collectSnappableTargets(WOutputRenderWindow *renderWindow) +{ + QList result; + if (!renderWindow || !renderWindow->contentItem()) + return result; + + QList> outputItems; + + auto items = + WOutputRenderWindow::paintOrderItemList(renderWindow->contentItem(), + [&outputItems](QQuickItem *item) -> bool { + if (!item->isVisible()) + return false; + if (qobject_cast(item)) { + outputItems.append(item); + return false; + } + if (qobject_cast(item)) + return true; + return false; + }); + + for (auto it = items.crbegin(); it != items.crend(); ++it) { + if (!*it) + continue; + auto surfaceItem = qobject_cast(*it); + if (!surfaceItem) + continue; + // Snap-mask surfaces are overlay masks and never snap targets. + if (auto *wrapper = snapMaskWrapperFor(surfaceItem); wrapper && wrapper->isSnapMask()) + continue; + SnappableTarget target; + target.rect = surfaceItem->mapRectToScene(surfaceItem->boundingRect()); + target.shellSurface = surfaceItem->shellSurface(); + result.append(target); + } + + for (const auto &item : std::as_const(outputItems)) { + if (!item) + continue; + SnappableTarget target; + target.rect = item->mapRectToScene(item->boundingRect()); + result.append(target); + } + + return result; +} + +SnappableTarget hitTestSnappableTarget(const QPointF &cursorPos, + const QList &snapshot) +{ + for (const auto &target : snapshot) { + if (target.rect.contains(cursorPos)) + return target; + } + return { }; +} +} // namespace + +class SnapTargetV1Private + : public QObject + , public QtWaylandServer::treeland_snap_target_v1 +{ +public: + explicit SnapTargetV1Private(QObject *parent = nullptr) + : QObject(parent) + , QtWaylandServer::treeland_snap_target_v1() + { + } + + ~SnapTargetV1Private() override + { + stopSnapping(); + } + + wl_global *globalHandle() const + { + return m_global; + } + + WOutputRenderWindow *renderWindow() const + { + return m_renderWindow; + } + + void setRenderWindow(WOutputRenderWindow *renderWindow) + { + m_renderWindow = renderWindow; + } + +protected: + void start(Resource *resource, struct ::wl_resource *seatResource, uint32_t events) override; + void stop(Resource *resource) override; + void destroy(Resource *resource) override; + void destroy_resource(Resource *resource) override; + +private: + void updateSnappableTarget(); + void sendSnappableTarget(const SnappableTarget &target); + void stopSnapping(); + + QPointer m_renderWindow; + QPointer m_seat; + + Resource *m_activeResource = nullptr; + bool m_pidfdEnabled = false; + QMetaObject::Connection m_cursorConn; + QList m_snapshot; + SnappableTarget m_lastTarget; +}; + +void SnapTargetV1Private::destroy(Resource *resource) +{ + wl_resource_destroy(resource->handle); +} + +void SnapTargetV1Private::destroy_resource(Resource *resource) +{ + if (m_activeResource == resource) + stopSnapping(); +} + +void SnapTargetV1Private::start(Resource *resource, + struct ::wl_resource *seatResource, + uint32_t events) +{ + // Only one snap session may be active across all clients. start on an + // already active snap object is ignored; start by another client fails + // with the snap_busy reason. + if (m_activeResource) { + if (m_activeResource != resource) + send_failed(resource->handle, failure_reason_snap_busy); + return; + } + + WSeat *seat = nullptr; + if (seatResource) { + auto *seatClient = wlr_seat_client_from_resource(seatResource); + if (seatClient) + seat = WSeat::fromHandle(seatClient->seat); + } + + m_activeResource = resource; + m_seat = seat; + m_pidfdEnabled = events & event_type_pidfd; + + m_snapshot = collectSnappableTargets(m_renderWindow); + + if (seat && seat->cursor()) { + m_cursorConn = connect(seat->cursor(), + &WCursor::positionChanged, + this, + &SnapTargetV1Private::updateSnappableTarget); + } + + updateSnappableTarget(); +} + +void SnapTargetV1Private::stop(Resource *resource) +{ + if (m_activeResource != resource) + return; + stopSnapping(); +} + +void SnapTargetV1Private::stopSnapping() +{ + if (!m_activeResource) + return; + + m_activeResource = nullptr; + m_seat = nullptr; + m_pidfdEnabled = false; + + if (m_cursorConn) { + disconnect(m_cursorConn); + m_cursorConn = QMetaObject::Connection(); + } + + m_snapshot.clear(); + m_lastTarget = SnappableTarget(); +} + +void SnapTargetV1Private::updateSnappableTarget() +{ + if (!m_activeResource) + return; + + auto seat = m_seat; + if (!seat || !seat->cursor()) + return; + + auto cursorPos = seat->cursor()->position(); + auto target = hitTestSnappableTarget(cursorPos, m_snapshot); + + if (target == m_lastTarget) + return; + + m_lastTarget = target; + sendSnappableTarget(target); +} + +void SnapTargetV1Private::sendSnappableTarget(const SnappableTarget &target) +{ + if (!m_activeResource) + return; + + // The pidfd event is sent only when the client enabled it and the target + // has a determinable owning process; it always precedes the snap_region + // event that commits it. + if (m_pidfdEnabled && !target.rect.isEmpty() && target.shellSurface) { + int pidfd = target.shellSurface->pidFD(); + if (pidfd >= 0) + send_pidfd(m_activeResource->handle, pidfd); + } + + const QRectF ®ion = target.rect; + if (region.isValid() && !region.isEmpty()) { + send_snap_region(m_activeResource->handle, + static_cast(region.x()), + static_cast(region.y()), + static_cast(region.width()), + static_cast(region.height())); + } else { + send_snap_region(m_activeResource->handle, 0, 0, 0, 0); + } +} + +SnapTargetV1::SnapTargetV1(QObject *parent) + : QObject(parent) + , WServerInterface() + , d(std::make_unique()) +{ +} + +SnapTargetV1::~SnapTargetV1() = default; + +WOutputRenderWindow *SnapTargetV1::outputRenderWindow() const +{ + return d->renderWindow(); +} + +void SnapTargetV1::setOutputRenderWindow(WOutputRenderWindow *renderWindow) +{ + d->setRenderWindow(renderWindow); +} + +QByteArrayView SnapTargetV1::interfaceName() const +{ + return QtWaylandServer::treeland_snap_target_v1::interfaceName(); +} + +void SnapTargetV1::create(WServer *server) +{ + d->init(server->handle(), InterfaceVersion); +} + +void SnapTargetV1::destroy([[maybe_unused]] WServer *server) +{ + d->globalRemove(); +} + +wl_global *SnapTargetV1::global() const +{ + return d->globalHandle(); +} diff --git a/src/modules/snap-target/snaphandler.h b/src/modules/snap-target/snaphandler.h new file mode 100644 index 000000000..72f0d6343 --- /dev/null +++ b/src/modules/snap-target/snaphandler.h @@ -0,0 +1,44 @@ +// Copyright (C) 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: Apache-2.0 OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only + +#pragma once + +#include +#include + +#include + +#include + +WAYLIB_SERVER_BEGIN_NAMESPACE +class WOutputRenderWindow; +WAYLIB_SERVER_END_NAMESPACE + +WAYLIB_SERVER_USE_NAMESPACE + +class SnapTargetV1Private; + +class SnapTargetV1 + : public QObject + , public WServerInterface +{ + Q_OBJECT +public: + explicit SnapTargetV1(QObject *parent = nullptr); + ~SnapTargetV1() override; + + static constexpr int InterfaceVersion = 1; + + WOutputRenderWindow *outputRenderWindow() const; + void setOutputRenderWindow(WOutputRenderWindow *renderWindow); + + QByteArrayView interfaceName() const override; + +protected: + void create(WServer *server) override; + void destroy(WServer *server) override; + wl_global *global() const override; + +private: + std::unique_ptr d; +}; diff --git a/src/surface/seatsurfacemanager.cpp b/src/surface/seatsurfacemanager.cpp index ed7fda188..19f0208c8 100644 --- a/src/surface/seatsurfacemanager.cpp +++ b/src/surface/seatsurfacemanager.cpp @@ -180,6 +180,8 @@ void SeatSurfaceManager::restoreShowDesktopFocus() void SeatSurfaceManager::beginMoveResize(SurfaceWrapper *surface, Qt::Edges edges) { + if (!surface) + return; if (m_moveResizeState.surface) endMoveResize(); // Move of a tiled/maximized window: instantly de-tile to Normal BEFORE diff --git a/src/surface/surfacewrapper.cpp b/src/surface/surfacewrapper.cpp index 1c176828d..7226b3daa 100644 --- a/src/surface/surfacewrapper.cpp +++ b/src/surface/surfacewrapper.cpp @@ -66,6 +66,7 @@ SurfaceWrapper::SurfaceWrapper(QmlEngine *qmlEngine, , m_isActivated(false) , m_attention(false) , m_isIMCandidatePanel(false) + , m_isSnapMask(false) , m_resizable(false) , m_maximizable(false) , m_modal(false) @@ -104,6 +105,7 @@ SurfaceWrapper::SurfaceWrapper(SurfaceWrapper *original, QQuickItem *parent) , m_isActivated(false) , m_attention(false) , m_isIMCandidatePanel(false) + , m_isSnapMask(false) , m_resizable(false) , m_maximizable(false) , m_modal(false) @@ -175,6 +177,7 @@ SurfaceWrapper::SurfaceWrapper(QmlEngine *qmlEngine, , m_isActivated(false) , m_attention(false) , m_isIMCandidatePanel(false) + , m_isSnapMask(false) , m_resizable(false) , m_maximizable(false) , m_modal(false) @@ -1131,6 +1134,9 @@ bool SurfaceWrapper::checkSetSurfaceState(State newSurfaceState, bool allowRetar if (currentState == newSurfaceState) return false; + if (m_isSnapMask && (newSurfaceState == State::Maximized || newSurfaceState == State::Tiling)) + return false; + if (container()->filterSurfaceStateChange(this, newSurfaceState, currentState)) return false; @@ -1360,6 +1366,20 @@ void SurfaceWrapper::setIMCandidatePanel(bool isIMCandidatePanel) Q_EMIT isIMCandidatePanelChanged(); } +bool SurfaceWrapper::isSnapMask() const +{ + return m_isSnapMask; +} + +void SurfaceWrapper::setSnapMask(bool snapMask) +{ + if (m_isSnapMask == snapMask) + return; + m_isSnapMask = snapMask; + Q_EMIT snapMaskChanged(); + updateSizeCapabilities(); +} + void SurfaceWrapper::setNoDecoration(bool newNoDecoration) { if (m_wrapperAboutToRemove) @@ -2525,7 +2545,7 @@ void SurfaceWrapper::setAlwaysOnBottom(bool alwaysOnBottom) bool SurfaceWrapper::showOnAllWorkspace() const { - if (m_type == Type::Layer || m_type == Type::XdgPopup || isInputPopupLike() + if (m_type == Type::Layer || m_type == Type::XdgPopup || isInputPopupLike() || isSnapMask() || surfaceRole() == SurfaceWrapper::SurfaceRole::PrivilegedOverlay) [[unlikely]] return true; return m_workspaceId == Workspace::ShowOnAllWorkspaceId; @@ -2714,8 +2734,10 @@ void SurfaceWrapper::updateSizeCapabilities() return; } - const bool resizable = m_shellSurface->hasCapability(WToplevelSurface::Capability::Resize); - const bool maximizable = m_shellSurface->hasCapability(WToplevelSurface::Capability::Maximized); + const bool resizable = + !m_isSnapMask && m_shellSurface->hasCapability(WToplevelSurface::Capability::Resize); + const bool maximizable = + !m_isSnapMask && m_shellSurface->hasCapability(WToplevelSurface::Capability::Maximized); if (m_resizable != resizable) { m_resizable = resizable; @@ -2779,6 +2801,10 @@ bool SurfaceWrapper::hasCapability(WToplevelSurface::Capability cap) const // Focus, Maximized, FullScreen, Resize return false; } + if (m_isSnapMask + && (cap == WToplevelSurface::Capability::Maximized + || cap == WToplevelSurface::Capability::Resize)) + return false; return m_shellSurface && m_shellSurface->hasCapability(cap); } diff --git a/src/surface/surfacewrapper.h b/src/surface/surfacewrapper.h index ab30ddd61..9f926aa5e 100644 --- a/src/surface/surfacewrapper.h +++ b/src/surface/surfacewrapper.h @@ -86,6 +86,7 @@ class SurfaceWrapper : public QQuickItem Q_PROPERTY(bool acceptKeyboardFocus READ acceptKeyboardFocus FINAL) Q_PROPERTY(bool isActivated READ isActivated NOTIFY isActivatedChanged FINAL) Q_PROPERTY(bool isIMCandidatePanel READ isIMCandidatePanel NOTIFY isIMCandidatePanelChanged FINAL) + Q_PROPERTY(bool isSnapMask READ isSnapMask NOTIFY snapMaskChanged FINAL) Q_PROPERTY(bool isResizable READ isResizable NOTIFY resizableChanged FINAL) Q_PROPERTY(bool isMaximizable READ isMaximizable NOTIFY maximizableChanged FINAL) Q_PROPERTY(bool modal READ modal NOTIFY modalChanged FINAL) @@ -348,6 +349,8 @@ class SurfaceWrapper : public QQuickItem bool isActivated() const; bool isIMCandidatePanel() const; void setIMCandidatePanel(bool isIMCandidatePanel); + bool isSnapMask() const; + void setSnapMask(bool snapMask); bool isInputPopupLike() const; bool isLaunchpad() const; bool isQuickLaunchpad() const; @@ -420,6 +423,7 @@ public Q_SLOTS: void aboutToBeInvalidated(); void isActivatedChanged(); void isIMCandidatePanelChanged(); + void snapMaskChanged(); void resizableChanged(); void maximizableChanged(); void modalChanged(); @@ -583,6 +587,7 @@ public Q_SLOTS: uint m_isActivated : 1; uint m_attention : 1; uint m_isIMCandidatePanel : 1; + uint m_isSnapMask : 1; uint m_resizable : 1; uint m_maximizable : 1; uint m_modal : 1;