diff --git a/.github/workflows/treeland-archlinux-build.yml b/.github/workflows/treeland-archlinux-build.yml index 986d790c4..93a4baeb0 100644 --- a/.github/workflows/treeland-archlinux-build.yml +++ b/.github/workflows/treeland-archlinux-build.yml @@ -34,7 +34,7 @@ jobs: - name: Install graphics and wayland dependencies run: | # Core graphics and wayland dependencies (matching qwlroots and waylib builds) - pacman -S --noconfirm --noprogressbar pixman vulkan-headers wayland wayland-protocols wlr-protocols libxcb + pacman -S --noconfirm --noprogressbar pixman vulkan-headers wayland wayland-protocols wlr-protocols libxcb mesa pacman -S --noconfirm --noprogressbar wlroots0.19 libinput pam systemd-libs jemalloc gcc-libs glibc - name: Install additional waylib/qwlroots dependencies diff --git a/debian/control b/debian/control index e4e62b698..18ac3cc75 100644 --- a/debian/control +++ b/debian/control @@ -20,6 +20,7 @@ Build-Depends: cmake (>= 3.25.0), libdrm-dev, pkg-config, qml6-module-qtquick-templates, + qml6-module-qtquick-effects, qt6-base-dev (>= 6.8.0), qt6-base-private-dev (>= 6.8.0), qt6-base-dev-tools (>= 6.8.0), @@ -50,6 +51,7 @@ Section: dde Architecture: any Multi-Arch: same Depends: qml6-module-qtquick-layouts, + qml6-module-qtquick-effects, qml6-module-qtquick-particles, treeland-data (= ${source:Version}), xwayland, diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index 20f94f0f6..df9803fc9 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -27,3 +27,4 @@ add_subdirectory(test_im_candidate_panel_tag) add_subdirectory(test_im_candidate_panel_xprop) add_subdirectory(test_input_manager) add_subdirectory(test_keyboard_state_notify) +add_subdirectory(test_glass) diff --git a/examples/test_capture/capture.cpp b/examples/test_capture/capture.cpp index 7785db05a..59a88e8c0 100644 --- a/examples/test_capture/capture.cpp +++ b/examples/test_capture/capture.cpp @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2024 UnionTech Software Technology Co., Ltd. +// SPDX-FileCopyrightText: 2024-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 "capture.h" @@ -8,6 +8,7 @@ #include #include +#include inline QtWaylandClient::QWaylandIntegration *waylandIntegration() { diff --git a/examples/test_glass/CMakeLists.txt b/examples/test_glass/CMakeLists.txt new file mode 100644 index 000000000..e15095587 --- /dev/null +++ b/examples/test_glass/CMakeLists.txt @@ -0,0 +1,99 @@ +# 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 + +find_package(Qt6 COMPONENTS Quick QuickControls2 ShaderTools REQUIRED) +qt_standard_project_setup(REQUIRES 6.4) + +if(QT_KNOWN_POLICY_QTP0001) # this policy was introduced in Qt 6.5 + qt_policy(SET QTP0001 NEW) + # the RESOURCE_PREFIX argument for qt_add_qml_module() defaults to ":/qt/qml/" +endif() +if(POLICY CMP0071) + # https://cmake.org/cmake/help/latest/policy/CMP0071.html + cmake_policy(SET CMP0071 NEW) +endif() + +set(QML_IMPORT_PATH "${PROJECT_BINARY_DIR}/src/server;${QML_IMPORT_PATH}" CACHE STRING "For LSP" FORCE) + +find_package(PkgConfig REQUIRED) +pkg_search_module(PIXMAN REQUIRED IMPORTED_TARGET pixman-1) +pkg_search_module(WAYLAND REQUIRED IMPORTED_TARGET wayland-server) + +set(GLASS_LIQUIDGLASS_SHADER_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../../misc/shaders") +set(GLASS_EFFECT_QML "${CMAKE_CURRENT_SOURCE_DIR}/../../src/core/qml/Effects/GlassEffect.qml") +set(GLASS_BLUR_QML "${CMAKE_CURRENT_SOURCE_DIR}/../../src/core/qml/Effects/Blur.qml") +set(LOCKSCREEN_ROUNDBLUR_QML "${CMAKE_CURRENT_SOURCE_DIR}/../../src/plugins/lockscreen/qml/RoundBlur.qml") +set_source_files_properties(${GLASS_EFFECT_QML} + PROPERTIES + QT_RESOURCE_ALIAS "GlassEffect.qml" +) +set_source_files_properties(${GLASS_BLUR_QML} + PROPERTIES + QT_RESOURCE_ALIAS "Blur.qml" +) +set_source_files_properties(${LOCKSCREEN_ROUNDBLUR_QML} + PROPERTIES + QT_RESOURCE_ALIAS "RoundBlur.qml" +) + +# ── Stub Treeland QML module ───────────────────────────────────────────── +# Provides a minimal Helper singleton with default-valued glass config so +# the upstream Blur.qml / RoundBlur.qml — which read `Helper.config.*` and +# `import Treeland` — work inside the standalone demo without libtreeland. +qt_add_library(treeland_stub SHARED) +qt_add_qml_module(treeland_stub + URI Treeland + VERSION "2.0" + QML_FILES + ${GLASS_EFFECT_QML} + ${GLASS_BLUR_QML} + ${LOCKSCREEN_ROUNDBLUR_QML} + SOURCES + helper.h + helper.cpp +) +qt_add_shaders(treeland_stub "treeland_stub_liquidglass_shaders" + PRECOMPILE + PREFIX + "/shaders" + BASE + ${GLASS_LIQUIDGLASS_SHADER_DIR} + FILES + ${GLASS_LIQUIDGLASS_SHADER_DIR}/liquidglass.vert + ${GLASS_LIQUIDGLASS_SHADER_DIR}/liquidglass.frag +) +target_compile_definitions(treeland_stub PRIVATE WLR_USE_UNSTABLE) +target_link_libraries(treeland_stub PUBLIC + Qt6::Quick + waylibserver + PkgConfig::PIXMAN + PkgConfig::WAYLAND +) + +# ── test_glass executable ──────────────────────────────────────────────── +add_executable(test_glass + main.cpp +) +qt_add_qml_module(test_glass + URI GlassExample + VERSION "1.0" + QML_FILES + Main.qml + RESOURCES + assets/default-glass-background.jpg +) +target_compile_definitions(test_glass + PRIVATE + SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}" + $<$:START_DEMO> + WLR_USE_UNSTABLE +) +target_link_libraries(test_glass + PRIVATE + Qt6::Quick + Qt6::QuickControls2 + treeland_stub + PkgConfig::PIXMAN + PkgConfig::WAYLAND +) +install(TARGETS test_glass DESTINATION ${CMAKE_INSTALL_BINDIR}) diff --git a/examples/test_glass/Main.qml b/examples/test_glass/Main.qml new file mode 100644 index 000000000..8c82afa58 --- /dev/null +++ b/examples/test_glass/Main.qml @@ -0,0 +1,460 @@ +// 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 +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Effects +import Waylib.Server +import GlassExample +import Treeland + +Item { + id :root + + readonly property url wallpaperSource: Helper.wallpaperSource + + property bool glassMode: true + property bool glassBlurEnabled: true + property real effectRadius: 34 + property real glassBezelWidth: 16 + property real glassThickness: 72 + property real glassDisplacementFactor: 0.65 + property real glassIor: 1.42 + property real glassDispersion: 0.012 + property real glassBrightness: 0.05 + property real glassContrast: -0.12 + property real glassSaturation: -0.15 + property real glassColorization: 0.12 + property int glassBlurMax: 36 + property real glassStrokeWidth: 1.4 + property real glassStrokeStrength: 1.5 + property real glassSpecularOpacity: 0.82 + property bool glassHighlightEnabled: true + property bool glassRimReflectionEnabled: true + property real glassLightAngle: -126.87 + property real glassLightPower: 3.0 + property real glassEdgeSaturation: 0.0 + property real glassReflectionOffset: 12 + property bool advancedExpanded: false + + Shortcut { + sequences: [StandardKey.Quit] + context: Qt.ApplicationShortcut + onActivated: { + Qt.quit() + } + } + + OutputRenderWindow { + id: renderWindow + + width: outputsContainer.implicitWidth + height: outputsContainer.implicitHeight + + Row { + id: outputsContainer + + anchors.fill: parent + + DynamicCreatorComponent { + id: outputDelegateCreator + creator: Helper.outputCreator + + OutputItem { + id: rootOutputItem + required property WaylandOutput waylandOutput + readonly property OutputViewport onscreenViewport: outputViewport + + output: waylandOutput + devicePixelRatio: waylandOutput.scale + + cursorDelegate: Cursor { + id: cursorItem + + required property QtObject outputCursor + readonly property point position: parent.mapFromGlobal(cursor.position.x, cursor.position.y) + + cursor: outputCursor.cursor + output: outputCursor.output.output + x: position.x - hotSpot.x + y: position.y - hotSpot.y + visible: valid && outputCursor.visible + OutputLayer.enabled: true + OutputLayer.keepLayer: true + OutputLayer.flags: OutputLayer.Cursor + OutputLayer.cursorHotSpot: hotSpot + OutputLayer.outputs: [outputViewport] + } + + OutputViewport { + id: outputViewport + + output: waylandOutput + devicePixelRatio: parent.devicePixelRatio + anchors.centerIn: parent + + RotationAnimation { + id: rotationAnimator + + target: outputViewport + duration: 200 + alwaysRunToEnd: true + } + + Timer { + id: setTransform + + property var scheduleTransform + onTriggered: onscreenViewport.rotateOutput(scheduleTransform) + interval: rotationAnimator.duration / 2 + } + + function rotationOutput(orientation) { + setTransform.scheduleTransform = orientation + setTransform.start() + + switch(orientation) { + case WaylandOutput.R90: + rotationAnimator.to = 90 + break + case WaylandOutput.R180: + rotationAnimator.to = 180 + break + case WaylandOutput.R270: + rotationAnimator.to = -90 + break + default: + rotationAnimator.to = 0 + break + } + + rotationAnimator.from = rotation + rotationAnimator.start() + } + } + + Image { + id: background + source: root.wallpaperSource + fillMode: Image.PreserveAspectCrop + asynchronous: true + anchors.fill: parent + } + + Column { + id: controlColumn + anchors { + bottom: parent.bottom + left: parent.left + margins: 10 + } + + spacing: 10 + + // ── Action buttons ────────────────────────── + Row { + spacing: 10 + Button { + text: root.glassMode ? "Switch to Blur" : "Switch to Glass" + onClicked: root.glassMode = !root.glassMode + } + Button { + text: "Grab Glass" + onClicked: { + effectPanel.grabToImage(function(result) { + const path = "/tmp/treeland-liquid-glass-grab.png" + if (result.saveToFile(path)) { + console.log("Liquid Glass grab saved", path, result.image.size) + } else { + console.warn("Liquid Glass grab failed", path) + } + }) + } + } + Button { + text: root.glassHighlightEnabled ? "Highlight: on" : "Highlight: off" + onClicked: root.glassHighlightEnabled = !root.glassHighlightEnabled + } + Button { + text: root.glassRimReflectionEnabled ? "Rim refl: on" : "Rim refl: off" + onClicked: root.glassRimReflectionEnabled = !root.glassRimReflectionEnabled + } + Button { + text: root.glassBlurEnabled ? "Blur: on" : "Blur: off" + onClicked: root.glassBlurEnabled = !root.glassBlurEnabled + } + } + + // ── Common sliders (always visible, single row) ── + Row { + spacing: 20 + + Column { + spacing: 2 + Label { text: "radius " + Math.round(root.effectRadius); color: "white" } + Slider { from: 0; to: 80; value: root.effectRadius; onMoved: root.effectRadius = value } + } + Column { + spacing: 2 + Label { text: "bezel " + Math.round(root.glassBezelWidth); color: "white" } + Slider { from: 1; to: 80; value: root.glassBezelWidth; onMoved: root.glassBezelWidth = value } + } + Column { + spacing: 2 + Label { text: "blur max " + root.glassBlurMax; color: "white" } + Slider { from: 0; to: 96; stepSize: 1; value: root.glassBlurMax; onMoved: root.glassBlurMax = value } + } + } + + // ── Advanced toggle ───────────────────────── + Button { + text: root.advancedExpanded ? "▼ Advanced" : "▶ Advanced" + onClicked: root.advancedExpanded = !root.advancedExpanded + } + + // ── Advanced sliders (collapsible, multi-column) ── + Row { + spacing: 20 + visible: root.advancedExpanded + + // Column A: glass material & optics + Column { + spacing: 2 + Label { text: "thickness " + Math.round(root.glassThickness); color: "white" } + Slider { from: 1; to: 200; value: root.glassThickness; onMoved: root.glassThickness = value } + Label { text: "displace " + root.glassDisplacementFactor.toFixed(2); color: "white" } + Slider { from: 0; to: 4; value: root.glassDisplacementFactor; onMoved: root.glassDisplacementFactor = value } + Label { text: "ior " + root.glassIor.toFixed(2); color: "white" } + Slider { from: 1.0; to: 2.5; value: root.glassIor; onMoved: root.glassIor = value } + Label { text: "dispersion " + root.glassDispersion.toFixed(3); color: "white" } + Slider { from: 0; to: 0.1; value: root.glassDispersion; onMoved: root.glassDispersion = value } + Label { text: "edge sat " + root.glassEdgeSaturation.toFixed(2); color: "white" } + Slider { from: 0; to: 1; value: root.glassEdgeSaturation; onMoved: root.glassEdgeSaturation = value } + } + + // Column B: specular & lighting + Column { + spacing: 2 + Label { text: "stroke " + root.glassStrokeWidth.toFixed(1); color: "white" } + Slider { from: 0; to: 8; value: root.glassStrokeWidth; onMoved: root.glassStrokeWidth = value } + Label { text: "stroke str " + root.glassStrokeStrength.toFixed(2); color: "white" } + Slider { from: 0; to: 2; value: root.glassStrokeStrength; onMoved: root.glassStrokeStrength = value } + Label { text: "spec opacity " + root.glassSpecularOpacity.toFixed(2); color: "white" } + Slider { from: 0; to: 1; value: root.glassSpecularOpacity; onMoved: root.glassSpecularOpacity = value } + Label { text: "light angle " + Math.round(root.glassLightAngle) + "°"; color: "white" } + Slider { from: -180; to: 180; value: root.glassLightAngle; onMoved: root.glassLightAngle = value } + Label { text: "light pow " + root.glassLightPower.toFixed(1); color: "white" } + Slider { from: 1; to: 16; value: root.glassLightPower; onMoved: root.glassLightPower = value } + Label { text: "refl offset " + Math.round(root.glassReflectionOffset); color: "white" } + Slider { from: 0; to: 60; value: root.glassReflectionOffset; onMoved: root.glassReflectionOffset = value } + } + + // Column C: colour grading (MultiEffect) + Column { + spacing: 2 + Label { text: "brightness " + root.glassBrightness.toFixed(2); color: "white" } + Slider { from: -1; to: 1; value: root.glassBrightness; onMoved: root.glassBrightness = value } + Label { text: "contrast " + root.glassContrast.toFixed(2); color: "white" } + Slider { from: -1; to: 1; value: root.glassContrast; onMoved: root.glassContrast = value } + Label { text: "saturation " + root.glassSaturation.toFixed(2); color: "white" } + Slider { from: -1; to: 1; value: root.glassSaturation; onMoved: root.glassSaturation = value } + Label { text: "colorization " + root.glassColorization.toFixed(2); color: "white" } + Slider { from: 0; to: 1; value: root.glassColorization; onMoved: root.glassColorization = value } + } + } + } + + Column { + anchors { + bottom: parent.bottom + right: parent.right + margins: 10 + } + + spacing: 10 + + Button { + text: "1X" + onClicked: { + onscreenViewport.setOutputScale(1) + } + } + + Button { + text: "1.5X" + onClicked: { + onscreenViewport.setOutputScale(1.5) + } + } + + Button { + text: "Normal" + onClicked: { + outputViewport.rotationOutput(WaylandOutput.Normal) + } + } + + Button { + text: "R90" + onClicked: { + outputViewport.rotationOutput(WaylandOutput.R90) + } + } + + Button { + text: "R270" + onClicked: { + outputViewport.rotationOutput(WaylandOutput.R270) + } + } + + Button { + text: "Quit" + onClicked: { + Qt.quit() + } + } + } + + Text { + anchors.centerIn: parent + text: "'Ctrl+Q' quit" + font.pointSize: 40 + color: "white" + + SequentialAnimation on rotation { + id: ani + running: true + PauseAnimation { duration: 1500 } + NumberAnimation { from: 0; to: 360; duration: 5000; easing.type: Easing.InOutCubic } + loops: Animation.Infinite + } + } + + + function setTransform(transform) { + onscreenViewport.rotationOutput(transform) + } + + function setScale(scale) { + onscreenViewport.setOutputScale(scale) + } + + function invalidate() { + onscreenViewport.invalidate() + } + } + } + } + + Item { + id: effectPanel + width: 500 + height: 100 + x: (parent.width - width) / 2 + y: (parent.height - height) / 2 + + MouseArea { + anchors.fill: parent + drag.target: effectPanel + drag.axis: Drag.XAndYAxis + drag.minimumX: 0 + drag.maximumX: parent.parent.width - effectPanel.width + drag.minimumY: 0 + drag.maximumY: parent.parent.height - effectPanel.height + } + + Loader { + anchors.fill: parent + sourceComponent: root.glassMode ? globalGlassComponent : globalBlurComponent + } + + Component { + id: globalGlassComponent + RenderBufferBlitter { + id: blitter + anchors.fill: parent + smooth: true + GlassEffect { + anchors.fill: parent + source: blitter.content + radius: root.effectRadius + blurEnabled: root.glassBlurEnabled + blurMax: root.glassBlurMax + bezelWidth: root.glassBezelWidth + thickness: root.glassThickness + displacementFactor: root.glassDisplacementFactor + ior: root.glassIor + dispersion: root.glassDispersion + brightness: root.glassBrightness + contrast: root.glassContrast + saturation: root.glassSaturation + colorization: root.glassColorization + strokeWidth: root.glassStrokeWidth + strokeStrength: root.glassStrokeStrength + specularOpacity: root.glassSpecularOpacity + highlightEnabled: root.glassHighlightEnabled + rimReflectionEnabled: root.glassRimReflectionEnabled + lightAngle: root.glassLightAngle + lightPower: root.glassLightPower + edgeSaturation: root.glassEdgeSaturation + reflectionOffset: root.glassReflectionOffset + } + } + } + + Component { + id: globalBlurComponent + RenderBufferBlitter { + id: blitter + anchors.fill: parent + MultiEffect { + anchors.fill: parent + source: blitter.content + autoPaddingEnabled: false + blurEnabled: true + blur: 1.0 + blurMax: 64 + saturation: 0.2 + } + } + } + } + + // ── Draggable RoundBlur demo ──────────────────────────────────── + // Uses the real RoundBlur component (Blur subclass) from the + // GlassExample QML module, which reads glass parameters from + // Helper.config. Drag to reposition. + Item { + id: roundBlurPanel + width: 30 + height: 30 + x: (parent.width - width) / 2 + 260 + y: (parent.height - height) / 2 - 180 + + MouseArea { + anchors.fill: parent + drag.target: roundBlurPanel + drag.axis: Drag.XAndYAxis + drag.minimumX: 0 + drag.maximumX: parent.parent.width - roundBlurPanel.width + drag.minimumY: 0 + drag.maximumY: parent.parent.height - roundBlurPanel.height + } + + RoundBlur { + anchors.fill: parent + radius: root.effectRadius + } + + Text { + anchors.left: parent.right + text: "RoundBlur\n(drag me)" + color: "white" + font.pixelSize: 14 + horizontalAlignment: Text.AlignHCenter + } + } + } +} diff --git a/examples/test_glass/assets/default-glass-background.jpg b/examples/test_glass/assets/default-glass-background.jpg new file mode 100644 index 000000000..d6cac6116 Binary files /dev/null and b/examples/test_glass/assets/default-glass-background.jpg differ diff --git a/examples/test_glass/helper.cpp b/examples/test_glass/helper.cpp new file mode 100644 index 000000000..195289cd9 --- /dev/null +++ b/examples/test_glass/helper.cpp @@ -0,0 +1,128 @@ +// 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 "helper.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +QW_USE_NAMESPACE + +GlassConfig::GlassConfig(QObject *parent) + : QObject(parent) +{ +} + +Helper::Helper(QObject *parent) + : QObject(parent) + , m_server(new WServer(this)) + , m_outputCreator(new WQmlCreator(this)) + , m_outputLayout(new WQuickOutputLayout(m_server)) + , m_cursor(new WCursor(this)) +{ + m_seat = m_server->attach(); + m_seat->setCursor(m_cursor); + m_cursor->setLayout(m_outputLayout); +} + +QString Helper::wallpaperSource() const +{ + return m_wallpaperSource; +} + +void Helper::setWallpaperSource(const QString &wallpaperSource) +{ + if (m_wallpaperSource == wallpaperSource) + return; + + m_wallpaperSource = wallpaperSource; + Q_EMIT wallpaperSourceChanged(); +} + +void Helper::initProtocols(WOutputRenderWindow *window, QQmlEngine *qmlEngine) +{ + m_backend = m_server->attach(); + m_server->start(); + + m_renderer = WRenderHelper::createRenderer(m_backend->handle()); + + if (!m_renderer) { + qFatal("Failed to create renderer"); + } + + connect(m_backend, &WBackend::outputAdded, this, [this, qmlEngine] (WOutput *output) { + auto initProperties = qmlEngine->newObject(); + initProperties.setProperty("waylandOutput", qmlEngine->toScriptValue(output)); + initProperties.setProperty("layout", qmlEngine->toScriptValue(m_outputLayout)); + initProperties.setProperty("x", qmlEngine->toScriptValue(m_outputLayout->implicitWidth())); + + m_outputCreator->add(output, initProperties); + }); + + connect(m_backend, &WBackend::outputRemoved, this, [this] (WOutput *output) { + m_outputCreator->removeByOwner(output); + }); + + connect(m_backend, &WBackend::inputAdded, this, [this] (WInputDevice *device) { + m_seat->attachInputDevice(device); + }); + + connect(m_backend, &WBackend::inputRemoved, this, [this] (WInputDevice *device) { + m_seat->detachInputDevice(device); + }); + + m_allocator = qw_allocator::autocreate(*m_backend->handle(), *m_renderer); + m_renderer->init_wl_display(*m_server->handle()); + + // free follow display + m_compositor = qw_compositor::create(*m_server->handle(), 6, *m_renderer); + qw_subcompositor::create(*m_server->handle()); + + connect(window, &WOutputRenderWindow::outputViewportInitialized, this, [] (WOutputViewport *viewport) { + // Trigger QWOutput::frame signal in order to ensure WOutputHelper::renderable + // property is true, OutputRenderWindow when will render this output in next frame. + { + WOutput *output = viewport->output(); + + // Enable on default + auto qwoutput = output->handle(); + // Don't care for WOutput::isEnabled, must do WOutput::commit here, + // In order to ensure trigger QWOutput::frame signal, WOutputRenderWindow + // needs this signal to render next frmae. Because QWOutput::frame signal + // maybe Q_EMIT before WOutputRenderWindow::attach, if no commit here, + // WOutputRenderWindow will ignore this ouptut on render. + if (!qwoutput->property("_Enabled").toBool()) { + qwoutput->setProperty("_Enabled", true); + qw_output_state newState; + + if (!qwoutput->handle()->current_mode) { + auto mode = qwoutput->preferred_mode(); + if (mode) + newState.set_mode(mode); + } + newState.set_enabled(true); + if (!qwoutput->commit_state(newState)) { + qCritical("commit failed on output %s", qwoutput->handle()->name); + } + } + } + }); + window->init(m_renderer, m_allocator); + + m_backend->handle()->start(); +} diff --git a/examples/test_glass/helper.h b/examples/test_glass/helper.h new file mode 100644 index 000000000..57aa8a4a4 --- /dev/null +++ b/examples/test_glass/helper.h @@ -0,0 +1,162 @@ +// 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 +#include + +// Minimal glass-effect configuration exposed to QML so that the upstream +// Blur.qml / RoundBlur.qml — which read `Helper.config.*` — work inside the +// standalone demo without the full treeland DConfig stack. +class GlassConfig : public QObject { + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + // Global Liquid Glass vs legacy MultiEffect dispatch + Q_PROPERTY(bool glassEnabled READ glassEnabled WRITE setGlassEnabled NOTIFY glassEnabledChanged) + // Blur tuning + Q_PROPERTY(int blurStrength READ blurStrength WRITE setBlurStrength NOTIFY blurStrengthChanged) + Q_PROPERTY(qreal blurAmount READ blurAmount WRITE setBlurAmount NOTIFY blurAmountChanged) + Q_PROPERTY(qreal blurMultiplier READ blurMultiplier WRITE setBlurMultiplier NOTIFY blurMultiplierChanged) + // Glass material parameters + Q_PROPERTY(qreal glassBezel READ glassBezel WRITE setGlassBezel NOTIFY glassBezelChanged) + Q_PROPERTY(qreal glassThickness READ glassThickness WRITE setGlassThickness NOTIFY glassThicknessChanged) + Q_PROPERTY(qreal glassDisplacementFactor READ glassDisplacementFactor WRITE setGlassDisplacementFactor NOTIFY glassDisplacementFactorChanged) + Q_PROPERTY(qreal glassIor READ glassIor WRITE setGlassIor NOTIFY glassIorChanged) + Q_PROPERTY(qreal glassDispersion READ glassDispersion WRITE setGlassDispersion NOTIFY glassDispersionChanged) + Q_PROPERTY(qreal glassBrightness READ glassBrightness WRITE setGlassBrightness NOTIFY glassBrightnessChanged) + Q_PROPERTY(qreal glassEdgeSaturation READ glassEdgeSaturation WRITE setGlassEdgeSaturation NOTIFY glassEdgeSaturationChanged) + Q_PROPERTY(qreal glassLightAngle READ glassLightAngle WRITE setGlassLightAngle NOTIFY glassLightAngleChanged) + Q_PROPERTY(qreal glassReflectionOffset READ glassReflectionOffset WRITE setGlassReflectionOffset NOTIFY glassReflectionOffsetChanged) + Q_PROPERTY(bool glassHighlightEnabled READ glassHighlightEnabled WRITE setGlassHighlightEnabled NOTIFY glassHighlightEnabledChanged) + +public: + explicit GlassConfig(QObject *parent = nullptr); + + static GlassConfig *create(QQmlEngine *, QJSEngine *) { return new GlassConfig; } + + bool glassEnabled() const { return m_glassEnabled; } + void setGlassEnabled(bool v) { if (m_glassEnabled != v) { m_glassEnabled = v; emit glassEnabledChanged(); } } + int blurStrength() const { return m_blurStrength; } + void setBlurStrength(int v) { if (m_blurStrength != v) { m_blurStrength = v; emit blurStrengthChanged(); } } + qreal blurAmount() const { return m_blurAmount; } + void setBlurAmount(qreal v) { if (m_blurAmount != v) { m_blurAmount = v; emit blurAmountChanged(); } } + qreal blurMultiplier() const { return m_blurMultiplier; } + void setBlurMultiplier(qreal v) { if (m_blurMultiplier != v) { m_blurMultiplier = v; emit blurMultiplierChanged(); } } + qreal glassBezel() const { return m_glassBezel; } + void setGlassBezel(qreal v) { if (m_glassBezel != v) { m_glassBezel = v; emit glassBezelChanged(); } } + qreal glassThickness() const { return m_glassThickness; } + void setGlassThickness(qreal v) { if (m_glassThickness != v) { m_glassThickness = v; emit glassThicknessChanged(); } } + qreal glassDisplacementFactor() const { return m_glassDisplacementFactor; } + void setGlassDisplacementFactor(qreal v) { if (m_glassDisplacementFactor != v) { m_glassDisplacementFactor = v; emit glassDisplacementFactorChanged(); } } + qreal glassIor() const { return m_glassIor; } + void setGlassIor(qreal v) { if (m_glassIor != v) { m_glassIor = v; emit glassIorChanged(); } } + qreal glassDispersion() const { return m_glassDispersion; } + void setGlassDispersion(qreal v) { if (m_glassDispersion != v) { m_glassDispersion = v; emit glassDispersionChanged(); } } + qreal glassBrightness() const { return m_glassBrightness; } + void setGlassBrightness(qreal v) { if (m_glassBrightness != v) { m_glassBrightness = v; emit glassBrightnessChanged(); } } + qreal glassEdgeSaturation() const { return m_glassEdgeSaturation; } + void setGlassEdgeSaturation(qreal v) { if (m_glassEdgeSaturation != v) { m_glassEdgeSaturation = v; emit glassEdgeSaturationChanged(); } } + qreal glassLightAngle() const { return m_glassLightAngle; } + void setGlassLightAngle(qreal v) { if (m_glassLightAngle != v) { m_glassLightAngle = v; emit glassLightAngleChanged(); } } + qreal glassReflectionOffset() const { return m_glassReflectionOffset; } + void setGlassReflectionOffset(qreal v) { if (m_glassReflectionOffset != v) { m_glassReflectionOffset = v; emit glassReflectionOffsetChanged(); } } + bool glassHighlightEnabled() const { return m_glassHighlightEnabled; } + void setGlassHighlightEnabled(bool v) { if (m_glassHighlightEnabled != v) { m_glassHighlightEnabled = v; emit glassHighlightEnabledChanged(); } } + +Q_SIGNALS: + void glassEnabledChanged(); + void blurStrengthChanged(); + void blurAmountChanged(); + void blurMultiplierChanged(); + void glassBezelChanged(); + void glassThicknessChanged(); + void glassDisplacementFactorChanged(); + void glassIorChanged(); + void glassDispersionChanged(); + void glassBrightnessChanged(); + void glassEdgeSaturationChanged(); + void glassLightAngleChanged(); + void glassReflectionOffsetChanged(); + void glassHighlightEnabledChanged(); + +private: + bool m_glassEnabled = true; + int m_blurStrength = 20; + qreal m_blurAmount = 1.0; + qreal m_blurMultiplier = 0.0; + qreal m_glassBezel = 32; + qreal m_glassThickness = 72; + qreal m_glassDisplacementFactor = 0.65; + qreal m_glassIor = 1.4; + qreal m_glassDispersion = 0.05; + qreal m_glassBrightness = 0.0; + qreal m_glassEdgeSaturation = 0.0; + qreal m_glassLightAngle = -127; + qreal m_glassReflectionOffset = 12.0; + bool m_glassHighlightEnabled = false; +}; + +WAYLIB_SERVER_BEGIN_NAMESPACE +class WServer; +class WOutputRenderWindow; +class WQuickOutputLayout; +class WCursor; +class WSeat; +class WBackend; +WAYLIB_SERVER_END_NAMESPACE + +QW_BEGIN_NAMESPACE +class qw_renderer; +class qw_allocator; +class qw_compositor; +QW_END_NAMESPACE + +WAYLIB_SERVER_USE_NAMESPACE +QW_USE_NAMESPACE + +class Helper : public QObject +{ + Q_OBJECT + Q_PROPERTY(WQmlCreator* outputCreator MEMBER m_outputCreator CONSTANT) + Q_PROPERTY(QString wallpaperSource READ wallpaperSource WRITE setWallpaperSource NOTIFY wallpaperSourceChanged) + Q_PROPERTY(GlassConfig* config READ config CONSTANT) + QML_ELEMENT + QML_SINGLETON + +public: + explicit Helper(QObject *parent = nullptr); + + void initProtocols(WOutputRenderWindow *window, QQmlEngine *qmlEngine); + + QString wallpaperSource() const; + void setWallpaperSource(const QString &wallpaperSource); + GlassConfig *config() const { return m_config; } +Q_SIGNALS: + void wallpaperSourceChanged(); + +public: + inline WBackend *backend() const { + return m_backend; + } + +private: + QString m_wallpaperSource; + WServer *m_server = nullptr; + WQmlCreator *m_outputCreator = nullptr; + GlassConfig *m_config = new GlassConfig(this); + + WBackend *m_backend = nullptr; + qw_renderer *m_renderer = nullptr; + qw_allocator *m_allocator = nullptr; + qw_compositor *m_compositor = nullptr; + WQuickOutputLayout *m_outputLayout = nullptr; + WCursor *m_cursor = nullptr; + QPointer m_seat; +}; diff --git a/examples/test_glass/main.cpp b/examples/test_glass/main.cpp new file mode 100644 index 000000000..fec3a8c3d --- /dev/null +++ b/examples/test_glass/main.cpp @@ -0,0 +1,71 @@ +// 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 "helper.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +QW_USE_NAMESPACE + +int main(int argc, char *argv[]) { + qw_log::init(); + WServer::initializeQPA(); +// QQuickStyle::setStyle("Material"); + + QGuiApplication::setAttribute(Qt::AA_UseOpenGLES); + QGuiApplication::setHighDpiScaleFactorRoundingPolicy(Qt::HighDpiScaleFactorRoundingPolicy::PassThrough); + QGuiApplication::setQuitOnLastWindowClosed(false); + QGuiApplication app(argc, argv); + + QCommandLineParser parser; + parser.setApplicationDescription(QStringLiteral("Waylib blur and glass effect demo")); + parser.addHelpOption(); + const QCommandLineOption wallpaperOption( + { QStringLiteral("w"), QStringLiteral("wallpaper") }, + QStringLiteral("Wallpaper image path or URL."), + QStringLiteral("path")); + parser.addOption(wallpaperOption); + parser.process(app); + + QString wallpaperSource = + QStringLiteral("qrc:/qt/qml/GlassExample/assets/default-glass-background.jpg"); + const QString wallpaperOverride = parser.value(wallpaperOption); + if (!wallpaperOverride.isEmpty()) { + wallpaperSource = QUrl::fromUserInput( + wallpaperOverride, QDir::currentPath(), QUrl::AssumeLocalFile).toString(); + } + + QQmlApplicationEngine waylandEngine; + waylandEngine.loadFromModule("GlassExample", "Main"); + + Helper *helper = waylandEngine.singletonInstance("Treeland", "Helper"); + Q_ASSERT(helper); + helper->setWallpaperSource(wallpaperSource); + + auto window = waylandEngine.rootObjects().first()->findChild(); + Q_ASSERT(window); + + helper->initProtocols(window, &waylandEngine); + + // multi output + qobject_cast(helper->backend()->handle())->for_each_backend([] (wlr_backend *backend, void *) { + if (auto x11 = qw_x11_backend::from(backend)) { + x11->output_create(); + } + }, nullptr); + + return app.exec(); +} diff --git a/misc/dconfig/org.deepin.dde.treeland.user.json b/misc/dconfig/org.deepin.dde.treeland.user.json index b9d0bfe8a..7d6147c1d 100644 --- a/misc/dconfig/org.deepin.dde.treeland.user.json +++ b/misc/dconfig/org.deepin.dde.treeland.user.json @@ -266,7 +266,6 @@ "permissions": "readwrite", "visibility": "public" }, - "workspaceThumbHeight": { "value": 144, "serial": 0, @@ -453,6 +452,160 @@ "description[zh_CN]": "用于管理系统当前使用的壁纸配置,包括每个屏幕和工作区的桌面壁纸和锁屏壁纸。", "permissions": "readwrite", "visibility": "public" + }, + "glassEnabled": { + "value": true, + "serial": 0, + "flags": [], + "name": "Glass Effect Enabled", + "name[zh_CN]": "液态玻璃效果开关", + "description": "Use the Liquid Glass effect for backdrop blur regions instead of the plain blur. When disabled, the traditional Gaussian blur is used with no side effects.", + "description[zh_CN]": "是否在背景模糊区域使用液态玻璃效果替代普通模糊。关闭时使用传统高斯模糊,无额外副作用。", + "permissions": "readwrite", + "visibility": "public" + }, + "blurStrength": { + "value": 60, + "serial": 0, + "flags": [], + "name": "Blur Strength", + "name[zh_CN]": "模糊强度", + "description": "Maximum blur strength (in pixels) applied to the backdrop blur regions. When set to 0, blur is completely disabled. Range: 0-128.", + "description[zh_CN]": "背景模糊的最大强度(像素)。设置为 0 时将完全关闭模糊效果。范围:0-128。", + "permissions": "readwrite", + "visibility": "public" + }, + "glassBezel": { + "value": 32, + "serial": 0, + "flags": [], + "name": "Glass Bezel Width", + "name[zh_CN]": "玻璃边缘厚度", + "description": "The width of the rounded edge bevel for the Liquid Glass effect. Controls how much light bends around the border.", + "description[zh_CN]": "液态玻璃效果的边缘倒角厚度,控制光线在边界处的折射强度。", + "permissions": "readwrite", + "visibility": "public" + }, + "glassThickness": { + "value": 72, + "serial": 0, + "flags": [], + "name": "Glass Base Thickness", + "name[zh_CN]": "玻璃基础厚度", + "description": "The base thickness of the Liquid Glass effect. Controls the overall strength of background image distortion/refraction.", + "description[zh_CN]": "液态玻璃效果的基础厚度,控制整体对背景图像的畸变/折射强度。", + "permissions": "readwrite", + "visibility": "public" + }, + "blurAmount": { + "value": 1.0, + "serial": 0, + "flags": [], + "name": "Blur Amount", + "name[zh_CN]": "模糊量", + "description": "Normalized blur amount applied by Qt MultiEffect. 0 disables the blur contribution while keeping the effect item alive; 1 uses the configured blur strength fully. Range: 0.0-1.0.", + "description[zh_CN]": "Qt MultiEffect 使用的归一化模糊量。0 表示关闭模糊贡献但保留效果项,1 表示完整使用配置的模糊强度。范围:0.0-1.0。", + "permissions": "readwrite", + "visibility": "public" + }, + "blurMultiplier": { + "value": 0.0, + "serial": 0, + "flags": [], + "name": "Blur Multiplier", + "name[zh_CN]": "模糊倍率", + "description": "Multiplier applied to extend the backdrop blur radius. 0 keeps Qt MultiEffect's default-quality blur radius; larger values extend the radius but reduce quality. Range: 0.0-4.0.", + "description[zh_CN]": "用于扩展背景模糊半径的倍率。0 表示使用 Qt MultiEffect 的默认质量模糊半径;值越大会扩展半径但降低质量。范围:0.0-4.0。", + "permissions": "readwrite", + "visibility": "public" + }, + "glassDisplacementFactor": { + "value": 0.65, + "serial": 0, + "flags": [], + "name": "Glass Displacement Factor", + "name[zh_CN]": "玻璃位移强度", + "description": "Scalar applied to Liquid Glass refraction displacement. Higher values bend the sampled backdrop more strongly. Range: 0.0-4.0.", + "description[zh_CN]": "液态玻璃折射位移的缩放系数。值越大,背景采样弯曲越明显。范围:0.0-4.0。", + "permissions": "readwrite", + "visibility": "public" + }, + "glassIor": { + "value": 1.4, + "serial": 0, + "flags": [], + "name": "Glass Refraction Index", + "name[zh_CN]": "玻璃折射率", + "description": "Refraction index used by the Liquid Glass material. Values near 1 reduce refraction; larger values bend light more. Range: 1.0-2.5.", + "description[zh_CN]": "液态玻璃材质使用的折射率。接近 1 时折射较弱,值越大光线弯折越明显。范围:1.0-2.5。", + "permissions": "readwrite", + "visibility": "public" + }, + "glassDispersion": { + "value": 0.05, + "serial": 0, + "flags": [], + "name": "Glass Dispersion", + "name[zh_CN]": "玻璃色散", + "description": "Chromatic dispersion amount for the Liquid Glass refraction. 0 disables RGB channel separation. Range: 0.0-0.1.", + "description[zh_CN]": "液态玻璃折射的色散强度。0 表示关闭 RGB 通道分离。范围:0.0-0.1。", + "permissions": "readwrite", + "visibility": "public" + }, + "glassBrightness": { + "value": 0.0, + "serial": 0, + "flags": [], + "name": "Glass Brightness", + "name[zh_CN]": "玻璃亮度", + "description": "Brightness adjustment applied to the Liquid Glass backdrop before refraction. 0 keeps the sampled backdrop unchanged. Range: -1.0-1.0.", + "description[zh_CN]": "液态玻璃折射前应用于背景采样的亮度调节。0 表示保持采样背景不变。范围:-1.0-1.0。", + "permissions": "readwrite", + "visibility": "public" + }, + "glassEdgeSaturation": { + "value": 0.0, + "serial": 0, + "flags": [], + "name": "Glass Edge Saturation", + "name[zh_CN]": "玻璃边缘饱和度", + "description": "Additional saturation applied only inside the Liquid Glass bevel/edge zone. 0 keeps the sampled backdrop unchanged. Range: 0.0-1.0.", + "description[zh_CN]": "仅应用在液态玻璃倒角/边缘区域的额外饱和度。0 表示保持采样背景不变。范围:0.0-1.0。", + "permissions": "readwrite", + "visibility": "public" + }, + "glassLightAngle": { + "value": -127, + "serial": 0, + "flags": [], + "name": "Glass Light Angle", + "name[zh_CN]": "玻璃光线角度", + "description": "Light direction angle in degrees for Liquid Glass optical modulation. 0 points right, -90 points up, 90 points down. Range: -180.0-180.0.", + "description[zh_CN]": "液态玻璃光学调制使用的光线方向角度(度)。0 向右,-90 向上,90 向下。范围:-180.0-180.0。", + "permissions": "readwrite", + "visibility": "public" + }, + "glassReflectionOffset": { + "value": 12.0, + "serial": 0, + "flags": [], + "name": "Glass Reflection Offset", + "name[zh_CN]": "玻璃反射偏移", + "description": "Backdrop sampling offset in pixels for Liquid Glass reflection/rim tint. 0 samples from the current pixel. Range: 0.0-60.0.", + "description[zh_CN]": "液态玻璃反射/边缘染色使用的背景采样偏移(像素)。0 表示从当前像素采样。范围:0.0-60.0。", + "permissions": "readwrite", + "visibility": "public" + }, + "glassHighlightEnabled": { + "value": false, + "serial": 0, + "flags": [], + "name": "Glass Highlight Enabled", + "name[zh_CN]": "玻璃高光开关", + "description": "Enable directional specular rim highlights on the Liquid Glass edge. When disabled, the glass shows only refraction and reflection without bright rim light.", + "description[zh_CN]": "在液态玻璃边缘启用方向性镜面高光。关闭时玻璃仅显示折射和反射,无明亮边缘高光。", + "permissions": "readwrite", + "visibility": "public" } } } diff --git a/misc/shaders/liquidglass.frag b/misc/shaders/liquidglass.frag new file mode 100644 index 000000000..99efd0aba --- /dev/null +++ b/misc/shaders/liquidglass.frag @@ -0,0 +1,322 @@ +// 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 + +#version 440 + +// Liquid Glass fragment shader — closely follows the liquid-dom reference +// implementation (https://github.com/AndrewPrifer/liquid-dom). +// +// Key concepts ported from liquid-dom's GLASS_SHADER (WGSL → GLSL 440): +// • Convex-squircle height profile for the beveled edge +// • Physical refraction via refract() with per-channel IOR dispersion +// • Luminance-gated environment reflection +// • Coloured edge specular (refracted ↔ reflected mix) +// • Additive white rim specular with configurable opacity +// • Tint, saturation, and brightness handled by MultiEffect (not shader) + +layout(location = 0) in vec2 texCoord; +layout(location = 0) out vec4 fragColor; + +layout(std140, binding = 0) uniform buf { + mat4 qt_Matrix; + float qt_Opacity; + vec2 itemSize; + float radius; // corner radius (px) + float bezelWidth; // edge bevel width (px) — liquid-dom bezelWidth + float thickness; // base glass thickness (px) — liquid-dom thickness + float displacementFactor; // scalar on displacement — liquid-dom displacementFactor + float ior; // refractive index — liquid-dom ior + float dispersion; // RGB channel separation — liquid-dom dispersion + vec4 highlightColor; // coloured specular tint (white in liquid-dom) + float strokeWidth; // specular rim band width (px) — liquid-dom specular.width + float strokeStrength; // specular strength — liquid-dom specular.strength + vec2 lightDirection; // 2D light dir (normalised at runtime) + float lightPower; // specular sharpness exponent — liquid-dom specular.sharpness + float edgeSaturation; // extra edge saturation boost + float reflectionOffset; // reflection sampling offset (px) — liquid-dom reflectionOffset + float specularOpacity; // white specular opacity — liquid-dom specular.opacity + float rimReflectionStrength; // 0 = current pure-white rim, >0 tints rim from nearby backdrop +} ubuf; + +layout(binding = 1) uniform sampler2D source; + +// --------------------------------------------------------------------------- +// SDF helpers (rounded rectangle) +// --------------------------------------------------------------------------- + +float roundedRectDistance(vec2 p, vec2 halfSize, float radius) +{ + float r = min(radius, min(halfSize.x, halfSize.y)); + vec2 q = abs(p) - halfSize + vec2(r); + return length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0) - r; +} + +vec2 roundedRectNormal(vec2 p, vec2 halfSize, float radius) +{ + float r = min(radius, min(halfSize.x, halfSize.y)); + vec2 inner = halfSize - vec2(r); + vec2 corner = clamp(p, -inner, inner); + vec2 delta = p - corner; + float len = length(delta); + + if (len > 0.0001) + return delta / len; + + vec2 edge = halfSize - abs(p); + if (edge.x < edge.y) + return vec2(sign(p.x), 0.0); + + return vec2(0.0, sign(p.y)); +} + +// --------------------------------------------------------------------------- +// Colour helpers +// --------------------------------------------------------------------------- + +vec3 applySaturation(vec3 color, float saturationValue) +{ + float luminance = dot(color, vec3(0.2126, 0.7152, 0.0722)); + return mix(vec3(luminance), color, saturationValue); +} + +vec4 sampleBackdrop(vec2 coord) +{ + return texture(source, clamp(coord, vec2(0.001), vec2(0.999))); +} + +// --------------------------------------------------------------------------- +// Height profile: convex squircle (from liquid-dom) +// Returns vec2(height, derivative) where x is bezelProgress [0..1]: +// x=0 at the shape boundary, x=1 at bezelWidth inside. +// The surface rises steeply at the edge and flattens toward the interior. +// --------------------------------------------------------------------------- + +vec2 convexSquircle(float x) +{ + float u = 1.0 - clamp(x, 0.0, 1.0); + float inside = max(1.0 - pow(u, 4.0), 0.0001); + float height = sqrt(inside); + float derivative = 2.0 * pow(u, 3.0) / sqrt(inside); + return vec2(height, derivative); +} + +// --------------------------------------------------------------------------- +// Fragment main +// --------------------------------------------------------------------------- + +void main() +{ + vec2 size = max(ubuf.itemSize, vec2(1.0)); + vec2 pixelSize = 1.0 / size; + vec2 pixel = texCoord * size; + vec2 centered = pixel - size * 0.5; + vec2 halfSize = size * 0.5; + + // SDF distance: negative inside, positive outside + float sdf = roundedRectDistance(centered, halfSize, ubuf.radius); + float distance = sdf; + vec2 normal = roundedRectNormal(centered, halfSize, ubuf.radius); + + // Fill mask: 1 inside, 0 outside. Use SDF derivatives for antialiasing + // so rounded corners stay smooth under scale/transform instead of using a + // fixed feather or the specular rim width. + float shapeAntialiasWidth = max(fwidth(sdf), 0.75); + float fillMask = 1.0 - smoothstep(0.0, shapeAntialiasWidth, distance); + + // ── Bevel zone (convex squircle height profile) ────────────────── + // Keep large bevels on straight edges, but avoid geometric singularities: + // 1. tiny items cannot fit opposing bevel bands, so cap by item size; + // 2. rounded corners cannot fit a bevel wider than the radius, so cap + // only inside the corner patch; + // 3. square/overlapping corners blend x/y edge normals instead of jumping + // between the nearest horizontal/vertical edge normal. + float configuredBezel = max(ubuf.bezelWidth, 1.0); + float maxSizeBezel = max( + min(configuredBezel, max(min(halfSize.x, halfSize.y) - shapeAntialiasWidth, 1.0)), + 1.0); + float inwardDistance = max(-distance, 0.0); + + vec2 edgeDistance = max(halfSize - abs(centered), vec2(0.0)); + float xEdgeInfluence = 1.0 - smoothstep(0.0, maxSizeBezel, edgeDistance.x); + float yEdgeInfluence = 1.0 - smoothstep(0.0, maxSizeBezel, edgeDistance.y); + vec2 edgeSign = sign(centered); + if (abs(edgeSign.x) < 0.0001) + edgeSign.x = 1.0; + if (abs(edgeSign.y) < 0.0001) + edgeSign.y = 1.0; + if (xEdgeInfluence + yEdgeInfluence > 0.0001) { + normal = normalize(vec2( + edgeSign.x * xEdgeInfluence, + edgeSign.y * yEdgeInfluence)); + } + + float cornerRadius = min(ubuf.radius, min(halfSize.x, halfSize.y)); + vec2 cornerStart = halfSize - vec2(cornerRadius); + vec2 cornerDelta = max(abs(centered) - cornerStart, vec2(0.0)); + float cornerMask = cornerRadius > 0.0 + ? smoothstep( + 0.0, + max(shapeAntialiasWidth, 1.0), + min(cornerDelta.x, cornerDelta.y)) + : 0.0; + float cornerBezel = cornerRadius > 0.0 + ? min(maxSizeBezel, max(cornerRadius - shapeAntialiasWidth, 1.0)) + : maxSizeBezel; + float bw = mix(maxSizeBezel, cornerBezel, cornerMask); + float bezelProgress = clamp(inwardDistance / bw, 0.0, 1.0); + + vec2 profileResult = convexSquircle(bezelProgress); + float profileHeight = profileResult.x * bw; + float flatHeight = convexSquircle(1.0).x * bw; + // Surface is thicker at the bevel edge, flattens in the interior + float surfaceHeight = ubuf.thickness + + (inwardDistance > bw ? flatHeight : profileHeight); + + // Surface slope from analytical derivative, clamped to ~85° + float surfaceDerivative = (inwardDistance > bw) ? 0.0 : profileResult.y; + float clampedSlope = min(surfaceDerivative, 11.43); // tan(1.4835) ≈ 85° + vec2 surfaceSlope = normal * clampedSlope; + + // 3D surface normal from 2D slope + vec3 surfaceNormal = normalize(vec3(surfaceSlope, 1.0)); + + // Shared light direction for both edge optics and specular rim. The + // physical view-ray refraction remains normal-based; this only modulates + // the bezel's optical strength so internal dispersion and border highlight + // read as one coherent light setup. + vec2 rawLightDir = ubuf.lightDirection; + vec2 lightDir = length(rawLightDir) > 0.0001 + ? normalize(rawLightDir) + : vec2(-0.70710678, -0.70710678); + float lightFacing = clamp(dot(normal, lightDir) * 0.5 + 0.5, 0.0, 1.0); + float directionalOpticalStrength = mix(0.85, 1.15, lightFacing); + + // ── Physical refraction with per-channel IOR dispersion ─────────── + float baseIor = max(ubuf.ior, 1.0001); + float disp = max(ubuf.dispersion, 0.0) * directionalOpticalStrength; + + vec3 refractedRayRed = refract( + vec3(0.0, 0.0, -1.0), surfaceNormal, + 1.0 / max(baseIor + disp, 1.0001)); + vec3 refractedRayGreen = refract( + vec3(0.0, 0.0, -1.0), surfaceNormal, + 1.0 / baseIor); + vec3 refractedRayBlue = refract( + vec3(0.0, 0.0, -1.0), surfaceNormal, + 1.0 / max(baseIor - disp, 1.0001)); + + // Per-channel pixel displacement (zero outside the shape) + float displaceScale = fillMask > 0.0 ? 1.0 : 0.0; + vec2 displacementRed = refractedRayRed.xy + / max(-refractedRayRed.z, 0.0001) + * surfaceHeight * ubuf.displacementFactor * directionalOpticalStrength * displaceScale; + vec2 displacementGreen = refractedRayGreen.xy + / max(-refractedRayGreen.z, 0.0001) + * surfaceHeight * ubuf.displacementFactor * directionalOpticalStrength * displaceScale; + vec2 displacementBlue = refractedRayBlue.xy + / max(-refractedRayBlue.z, 0.0001) + * surfaceHeight * ubuf.displacementFactor * directionalOpticalStrength * displaceScale; + + vec2 refractedUvRed = texCoord + displacementRed * pixelSize; + vec2 refractedUvGreen = texCoord + displacementGreen * pixelSize; + vec2 refractedUvBlue = texCoord + displacementBlue * pixelSize; + + // Sample backdrop with refraction (liquid-dom uses blurred texture here) + vec3 refractedColor = vec3( + sampleBackdrop(refractedUvRed).r, + sampleBackdrop(refractedUvGreen).g, + sampleBackdrop(refractedUvBlue).b + ); + + // ── Environment reflection (luminance-gated) ────────────────────── + vec2 reflectedUv = texCoord + normal * ubuf.reflectionOffset * pixelSize; + vec3 reflectedColor = sampleBackdrop(reflectedUv).rgb; + + // Glass interior = refracted colour (no tint/exposure — MultiEffect handles those) + vec3 glass = refractedColor; + + // Reflection only shows when reflected area is bright AND refracted area is dark + float refractedLuma = dot(refractedColor, vec3(0.2126, 0.7152, 0.0722)); + float reflectedLuma = dot(reflectedColor, vec3(0.2126, 0.7152, 0.0722)); + float reflectionPresence = smoothstep(0.2, 0.85, reflectedLuma); + float refractionAcceptance = 1.0 - smoothstep(0.35, 0.85, refractedLuma); + float reflectionBlend = reflectionPresence * refractionAcceptance; + vec3 edgeSpecularColor = mix(refractedColor, reflectedColor, reflectionBlend); + + // ── Edge-local saturation boost (global saturation is MultiEffect's job) ── + // Glass material only affects the bezel zone. The flat interior shows + // the original backdrop untouched — like a transparent window with + // beveled edges. + vec3 glassInterior = glass; + float edgeInfluence = 1.0 - smoothstep(0.0, bw, inwardDistance); + // edgeSaturation is an additive boost on top of MultiEffect's global saturation + glassInterior = applySaturation(glassInterior, 1.0 + edgeInfluence * ubuf.edgeSaturation); + + // ── Specular rim highlights (directional, from liquid-dom) ─────── + float rimWidthPx = max(ubuf.strokeWidth, 0.0001); + float specularInwardDistancePx = max(-distance, 0.0); + float specularOuterMask = 1.0 - smoothstep(0.0, 1.0, max(distance, 0.0)); + float specularInnerMask = 1.0 - smoothstep( + rimWidthPx, rimWidthPx + 1.0, specularInwardDistancePx); + float rimBandMask = specularOuterMask * specularInnerMask; + + // Inward progress: 0 at boundary, 1 at rimWidth inside + float inwardProgress = clamp( + specularInwardDistancePx / max(rimWidthPx, 1.0), 0.0, 1.0); + // Quadratic falloff (liquid-dom: specularFalloff * progress²) + float strengthFalloff = 1.0 - inwardProgress * inwardProgress * 0.5; + + vec2 specularNormal = lightDir; + vec2 rimReflectionUv = texCoord + + specularNormal * ubuf.reflectionOffset * pixelSize; + vec3 rimReflectionColor = sampleBackdrop(rimReflectionUv).rgb; + vec3 rimReflectionTint = mix( + vec3(1.0), rimReflectionColor, ubuf.rimReflectionStrength); + float normalAlignment = dot(normal, specularNormal); + + // Primary and opposite rim contributions are both derived from the same + // configurable specular normal, matching the reference "Specular Angle" + // control: rotating one normal rotates the whole rim highlight pattern. + float rimSpecular = pow(max(normalAlignment, 0.0), ubuf.lightPower); + float oppositeRimSpecular = pow(max(-normalAlignment, 0.0), ubuf.lightPower); + + float primaryOpacity = clamp( + rimSpecular * ubuf.strokeStrength * strengthFalloff, 0.0, 1.0); + float oppositeOpacity = clamp( + oppositeRimSpecular * ubuf.strokeStrength * strengthFalloff, 0.0, 1.0); + float combinedSpecular = clamp( + (primaryOpacity + oppositeOpacity) * rimBandMask, 0.0, 1.0); + + // White specular (additive, modulated by specularOpacity) + float whiteSpecularOpacity = combinedSpecular * ubuf.specularOpacity; + vec3 whiteSpecular = rimReflectionTint * whiteSpecularOpacity; + + // Coloured specular tint (highlightColor, for compatibility with + // liquid-dom's pure-white specular we default highlightColor to white) + vec3 coloredSpecular = ubuf.highlightColor.rgb + * ubuf.highlightColor.a + * combinedSpecular * 0.5; + + // ── Compositing (from liquid-dom) ───────────────────────────────── + vec3 background = sampleBackdrop(texCoord).rgb; + vec3 color = background; + if (fillMask > 0.0) { + // 1. Glass interior (refracted + edge saturation boost) + // Limited to the bezel edge zone; center is transparent. + color = mix(color, glassInterior, edgeInfluence * fillMask); + // 2. Coloured edge specular (refracted ↔ reflected mix) — edge only + color = mix(color, edgeSpecularColor, combinedSpecular * edgeInfluence * fillMask); + // 3. White specular (additive) — rim band only + color = color + whiteSpecular * fillMask; + // 4. Coloured specular tint — rim band only + color = color + coloredSpecular * fillMask; + } + + // ── Corner alpha (rounded-rect clip, SDF derivative antialiasing) ── + float cornerAlpha = 1.0 - smoothstep(0.0, shapeAntialiasWidth, max(sdf, 0.0)); + // Premultiplied alpha: transparent pixels must have zero RGB too, + // otherwise MultiEffect's blur bleeds false colour (white corners) + // when sampling the layer texture. + float outAlpha = cornerAlpha * ubuf.qt_Opacity; + fragColor = vec4(clamp(color, vec3(0.0), vec3(1.0)) * outAlpha, outAlpha); +} diff --git a/misc/shaders/liquidglass.vert b/misc/shaders/liquidglass.vert new file mode 100644 index 000000000..2c32300ca --- /dev/null +++ b/misc/shaders/liquidglass.vert @@ -0,0 +1,22 @@ +// 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 + +#version 440 + +layout(location = 0) in vec4 qt_Vertex; +layout(location = 1) in vec2 qt_MultiTexCoord0; + +layout(location = 0) out vec2 texCoord; + +layout(std140, binding = 0) uniform vert_buf { + mat4 qt_Matrix; + float qt_Opacity; +} vbuf; + +out gl_PerVertex { vec4 gl_Position; }; + +void main() +{ + texCoord = qt_MultiTexCoord0; + gl_Position = vbuf.qt_Matrix * qt_Vertex; +} diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 5a0504aff..b81371c65 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -254,6 +254,7 @@ qt_add_qml_module(libtreeland core/qml/Animations/LaunchpadAnimation.qml core/qml/Animations/LayerShellAnimation.qml core/qml/Effects/Blur.qml + core/qml/Effects/GlassEffect.qml core/qml/Effects/LaunchpadCover.qml core/qml/TaskSwitcher.qml core/qml/TaskWindowPreview.qml @@ -292,6 +293,19 @@ qt_add_shaders(libtreeland "treeland_shaders_ng" ${PROJECT_RESOURCES_DIR}/shaders/radiussmoothtexture.frag ) +# Liquid Glass shaders are used with ShaderEffect, not custom QSGGeometry, +# so BATCHABLE is unnecessary (it only adds an unused batchable variant). +qt_add_shaders(libtreeland "treeland_liquidglass_shaders" + PRECOMPILE + PREFIX + "/shaders" + BASE + ${PROJECT_RESOURCES_DIR}/shaders + FILES + ${PROJECT_RESOURCES_DIR}/shaders/liquidglass.vert + ${PROJECT_RESOURCES_DIR}/shaders/liquidglass.frag +) + qt_add_resources(libtreeland "treeland_assets" PREFIX "/dsg/icons" BASE ${PROJECT_RESOURCES_DIR}/icons diff --git a/src/core/qml/Effects/Blur.qml b/src/core/qml/Effects/Blur.qml index db1332341..b89fe71a8 100644 --- a/src/core/qml/Effects/Blur.qml +++ b/src/core/qml/Effects/Blur.qml @@ -1,4 +1,4 @@ -// Copyright (C) 2024 UnionTech Software Technology Co., Ltd. +// Copyright (C) 2024-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 @@ -8,43 +8,100 @@ import Waylib.Server import Treeland RenderBufferBlitter { + id: blitter + smooth: true + property real radius: 0 property bool radiusEnabled: radius > 0 - property alias blurMax: blur.blurMax - property alias blurEnabled: blur.blurEnabled - property alias multiplier: blur.blurMultiplier + property int blurMax: Helper.config.blurStrength + property bool blurEnabled: blurMax > 0 && blurAmount > 0 + property real blurAmount: Helper.config.blurAmount + property real multiplier: Helper.config.blurMultiplier + property real brightness: Helper.config.glassBrightness + property real lightAngle: Helper.config.glassLightAngle + property bool highlightEnabled: Helper.config.glassHighlightEnabled + property bool glassEnabled: Helper.config.glassEnabled - id: blitter z: parent.z ? parent.z - 1 : -1 anchors.fill: parent - MultiEffect { - id: blur + + // Dispatch between Liquid Glass and traditional blur via a Loader so only + // the active branch is instantiated. Toggling the DConfig key unloads one + // Component and loads the other. + Loader { anchors.fill: parent - layer.enabled: blitter.radiusEnabled - smooth: blitter.radiusEnabled - opacity: blitter.radiusEnabled ? 0 : parent.opacity - source: blitter.content - autoPaddingEnabled: false - blurEnabled: true - blur: 1.0 - blurMax: 64 - saturation: 0.2 + sourceComponent: blitter.glassEnabled ? glassComponent : blurComponent } - Loader { - x: blur.x - y: blur.y - active: blitter.radiusEnabled - sourceComponent: Shape { + Component { + id: glassComponent + GlassEffect { + anchors.fill: parent + source: blitter.content + radius: blitter.radius + blurEnabled: blitter.blurEnabled + blurMax: blitter.blurMax + blurAmount: blitter.blurAmount + blurMultiplier: blitter.multiplier + brightness: blitter.brightness + highlightEnabled: blitter.highlightEnabled + lightAngle: blitter.lightAngle + + bezelWidth: Helper.config.glassBezel + thickness: Helper.config.glassThickness + displacementFactor: Helper.config.glassDisplacementFactor + ior: Helper.config.glassIor + dispersion: Helper.config.glassDispersion + contrast: -0.12 + saturation: -0.15 + colorization: 0.12 + edgeSaturation: Helper.config.glassEdgeSaturation + highlightColor: Qt.rgba(1, 1, 1, 0.3) + strokeWidth: 0.5 + strokeStrength: 1.5 + specularOpacity: 0.82 + rimReflectionEnabled: true + lightPower: 3.0 + reflectionOffset: Helper.config.glassReflectionOffset + } + } + + Component { + id: blurComponent + Item { anchors.fill: parent - preferredRendererType: Shape.CurveRenderer - ShapePath { - strokeWidth: 0 - fillItem: blur - PathRectangle { - width: blur.width - height: blur.height - radius: blitter.radius + + MultiEffect { + id: blur + anchors.fill: parent + layer.enabled: blitter.radiusEnabled + smooth: blitter.radiusEnabled + opacity: blitter.radiusEnabled ? 0 : blitter.opacity + source: blitter.content + autoPaddingEnabled: false + blurEnabled: blitter.blurEnabled + blur: blitter.blurAmount + blurMax: blitter.blurMax + blurMultiplier: blitter.multiplier + saturation: 0.2 + } + + Loader { + x: blur.x + y: blur.y + active: blitter.radiusEnabled + sourceComponent: Shape { + anchors.fill: parent + preferredRendererType: Shape.CurveRenderer + ShapePath { + strokeWidth: 0 + fillItem: blur + PathRectangle { + width: blur.width + height: blur.height + radius: blitter.radius + } + } } } } diff --git a/src/core/qml/Effects/GlassEffect.qml b/src/core/qml/Effects/GlassEffect.qml new file mode 100644 index 000000000..024a7959d --- /dev/null +++ b/src/core/qml/Effects/GlassEffect.qml @@ -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 + +import QtQuick +import QtQuick.Effects + +Item { + id: effect + + required property variant source + + property real radius: 0 + property bool blurEnabled: false + property int blurMax: 32 + property real blurAmount: 1.0 + property real blurMultiplier: 0.0 + + // Glass material parameters (aligned with liquid-dom naming) + property real bezelWidth: 18 // edge bevel width (px) + property real thickness: 90 // base glass thickness (px) + property real displacementFactor: 1 // scalar on displacement + property real ior: 1.5 // refractive index + property real dispersion: 0.02 // RGB channel separation + + // Colour controls — applied to the backdrop BEFORE refraction, so the + // glass specular / rim highlights stay sharp and uncoloured. + // Delegated to MultiEffect (avoid duplicate implementation) + property real brightness: 0.0 // [-1, 1], 0 = no change + property real contrast: 0.0 // [-1, 1], 0 = no change + property real saturation: 0.0 // [-1, 1], 0 = no change + property real colorization: 0.0 // [0, 1], 0 = no tint + property color colorizationColor: Qt.rgba(1, 1, 1, 1) + + // Edge-local saturation boost — MultiEffect can't do spatially-varying, so + // this stays in the shader. Only affects the bezel zone. + property real edgeSaturation: 0.0 + + // Specular / highlight controls + property color highlightColor: Qt.rgba(1, 1, 1, 0.35) + property real strokeWidth: 1.5 // specular rim band width (px) + property real strokeStrength: 1.0 // specular strength + property real specularOpacity: 0.6 // white specular opacity (liquid-dom demo value) + property bool highlightEnabled: false // master toggle for edge specular highlights + + // Rim reflection tint: when enabled, the white specular rim picks up a + // small amount of nearby backdrop colour. The shader receives only a + // float gate to avoid bool/int uniform binding issues on GLES drivers. + property bool rimReflectionEnabled: true + + // Light direction. Degrees; 0 points right, -90 points up. + property real lightAngle: -135.0 + readonly property real lightAngleRadians: lightAngle * Math.PI / 180 + property real lightPower: 2.0 // specular sharpness exponent + readonly property vector2d lightDirection: Qt.vector2d( + Math.cos(effect.lightAngleRadians), + Math.sin(effect.lightAngleRadians)) + + // Reflection + property real reflectionOffset: 18 // reflection sampling offset (px) + + + // True when MultiEffect is needed for blur or colour grading of the backdrop + readonly property bool multiEffectEnabled: + (blurEnabled && blurAmount > 0 && blurMax > 0) + || brightness != 0 || contrast != 0 || saturation != 0 || colorization > 0 + + anchors.fill: parent + + // ── Stage 1: blur + colour-grade the raw backdrop ────────────────── + // NOT directly visible — its layer texture is sampled by the glass + // shader. Blur happens here so the glass specular / rim highlights + // added in Stage 2 stay sharp. + MultiEffect { + id: blurredSource + anchors.fill: parent + visible: effect.multiEffectEnabled + layer.enabled: effect.multiEffectEnabled + smooth: true + opacity: 0 // not drawn to screen; sampled as a texture + source: effect.source + autoPaddingEnabled: false + blurEnabled: effect.blurEnabled && effect.blurAmount > 0 + blur: blurEnabled ? effect.blurAmount : 0.0 + blurMax: effect.blurMax + blurMultiplier: effect.blurMultiplier + brightness: effect.brightness + contrast: effect.contrast + saturation: effect.saturation + colorization: effect.colorization + colorizationColor: effect.colorizationColor + } + + // ── Stage 2: glass shader refracts the (blurred) backdrop and adds + // specular highlights / rim light on top. This item intentionally + // owns no rounded Shape clip; wrappers such as Glass.qml provide that. + ShaderEffect { + id: glassShader + objectName: "glassShader" + anchors.fill: parent + property variant source: effect.multiEffectEnabled ? blurredSource : effect.source + readonly property vector2d itemSize: Qt.vector2d(Math.max(width, 1), Math.max(height, 1)) + readonly property real radius: effect.radius + readonly property real bezelWidth: effect.bezelWidth + readonly property real thickness: effect.thickness + readonly property real displacementFactor: effect.displacementFactor + readonly property real ior: effect.ior + readonly property real dispersion: effect.dispersion + readonly property color highlightColor: effect.highlightColor + readonly property real strokeWidth: effect.strokeWidth + readonly property real specularOpacity: effect.highlightEnabled ? effect.specularOpacity : 0.0 + readonly property real strokeStrength: effect.highlightEnabled ? effect.strokeStrength : 0.0 + readonly property real rimReflectionStrength: effect.rimReflectionEnabled ? 0.22 : 0.0 + readonly property vector2d lightDirection: effect.lightDirection + readonly property real lightPower: effect.lightPower + readonly property real edgeSaturation: effect.edgeSaturation + readonly property real reflectionOffset: effect.reflectionOffset + vertexShader: "qrc:/shaders/liquidglass.vert.qsb" + fragmentShader: "qrc:/shaders/liquidglass.frag.qsb" + } +} diff --git a/src/core/shellhandler.cpp b/src/core/shellhandler.cpp index 65e8d5069..495c0de3c 100644 --- a/src/core/shellhandler.cpp +++ b/src/core/shellhandler.cpp @@ -858,7 +858,7 @@ void ShellHandler::setupSurfaceActiveWatcher(SurfaceWrapper *wrapper) // When the popup surface gains focus capability (mapped + grab active), // move keyboard focus to the popup. PopupFocusManager handles // grab tracking and focus restoration when the grab ends. - connect(wrapper, &SurfaceWrapper::hasFocusCapabilityChanged, this, [this, wrapper]() { + connect(wrapper, &SurfaceWrapper::hasFocusCapabilityChanged, this, [wrapper]() { if (!wrapper->hasFocusCapability()) return; if (auto *pfm = Helper::instance()->popupFocusManager()) { diff --git a/src/plugins/lockscreen/qml/RoundBlur.qml b/src/plugins/lockscreen/qml/RoundBlur.qml index 9196b0dc8..581a24eab 100644 --- a/src/plugins/lockscreen/qml/RoundBlur.qml +++ b/src/plugins/lockscreen/qml/RoundBlur.qml @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2024 UnionTech Software Technology Co., Ltd. +// SPDX-FileCopyrightText: 2024-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 @@ -7,7 +7,13 @@ import org.deepin.dtk 1.0 as D Blur { id: root + glassEnabled: false + blurMax: 64 + brightness: 0 + highlightEnabled: false + property color color: Qt.rgba(1, 1, 1, 0.1) + Rectangle { anchors.fill: parent radius: root.radius diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 5f127b0db..a9edf9590 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -8,3 +8,4 @@ add_subdirectory(test_protocol_virtual-output) add_subdirectory(test_protocol_wallpaper-color) add_subdirectory(test_protocol_window-management) add_subdirectory(test_protocol_prelaunch-splash) +add_subdirectory(test_effect_glass) diff --git a/tests/test_effect_glass/CMakeLists.txt b/tests/test_effect_glass/CMakeLists.txt new file mode 100644 index 000000000..69f72c9f4 --- /dev/null +++ b/tests/test_effect_glass/CMakeLists.txt @@ -0,0 +1,86 @@ +# 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 + +# Liquid Glass effect test — uses waylib's headless backend for a real +# OpenGL context (via Mesa llvmpipe) without a display server or xvfb-run. +# The TestGlass QML module bundles GlassEffect.qml + shaders; TestWindow.qml +# wraps the test scene in an OutputRenderWindow + OutputItem so it renders +# through waylib's scene graph. + +find_package(Qt6 REQUIRED COMPONENTS Test Core Qml Quick QuickControls2 ShaderTools) + +qt_standard_project_setup(REQUIRES 6.4) + +if(QT_KNOWN_POLICY_QTP0001) + qt_policy(SET QTP0001 NEW) +endif() +if(POLICY CMP0071) + cmake_policy(SET CMP0071 NEW) +endif() + +find_package(PkgConfig REQUIRED) +pkg_search_module(PIXMAN REQUIRED IMPORTED_TARGET pixman-1) +pkg_search_module(WAYLAND REQUIRED IMPORTED_TARGET wayland-server) + +set(GLASS_SHADER_DIR "${CMAKE_SOURCE_DIR}/misc/shaders") +set(GLASS_EFFECT_QML "${CMAKE_SOURCE_DIR}/src/core/qml/Effects/GlassEffect.qml") +set_source_files_properties(${GLASS_EFFECT_QML} + PROPERTIES + QT_RESOURCE_ALIAS "GlassEffect.qml" +) + +add_executable(test_effect_glass + main.cpp + TestHelper.cpp +) + +qt_add_qml_module(test_effect_glass + URI TestGlass + VERSION "1.0" + QML_FILES + ${GLASS_EFFECT_QML} + GlassEffectScene.qml + TestWindow.qml + SOURCES + TestHelper.h +) + +qt_add_shaders(test_effect_glass "test_glass_shaders" + PRECOMPILE + PREFIX + "/shaders" + BASE + ${GLASS_SHADER_DIR} + FILES + ${GLASS_SHADER_DIR}/liquidglass.vert + ${GLASS_SHADER_DIR}/liquidglass.frag +) + +target_compile_definitions(test_effect_glass + PRIVATE + SOURCE_DIR="${CMAKE_SOURCE_DIR}" + WLR_USE_UNSTABLE +) + +target_link_libraries(test_effect_glass + PRIVATE + Qt::Core + Qt::Qml + Qt::Quick + Qt::QuickControls2 + Qt::Test + waylibserver + PkgConfig::PIXMAN + PkgConfig::WAYLAND +) + +# waylib QML imports are generated in the build tree. +set_target_properties(test_effect_glass PROPERTIES + QT_QML_IMPORT_PATH "${PROJECT_BINARY_DIR}/waylib/src/server" +) + +add_test(NAME test_effect_glass COMMAND test_effect_glass) +set_tests_properties(test_effect_glass PROPERTIES + ENVIRONMENT "WLR_BACKENDS=headless;QT_QML_IMPORT_PATH=${PROJECT_BINARY_DIR}/waylib/src/server" + TIMEOUT 60 +) diff --git a/tests/test_effect_glass/GlassEffectScene.qml b/tests/test_effect_glass/GlassEffectScene.qml new file mode 100644 index 000000000..3f9043d18 --- /dev/null +++ b/tests/test_effect_glass/GlassEffectScene.qml @@ -0,0 +1,84 @@ +// 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 +import QtQuick.Effects +import TestGlass + +Item { + id: root + width: 256 + height: 256 + + /// Expose the GlassEffect instance for property manipulation from C++. + property alias glass: glassEffect + + /// A colourful, high-contrast backdrop so refraction / highlights / rim + /// tint are clearly visible in grabbed images. + Rectangle { + id: backdrop + anchors.fill: parent + visible: true + layer.enabled: true + + gradient: Gradient { + orientation: Gradient.Horizontal + GradientStop { position: 0.0; color: "#c0392b" } + GradientStop { position: 0.33; color: "#27ae60" } + GradientStop { position: 0.66; color: "#2980b9" } + GradientStop { position: 1.0; color: "#8e44ad" } + } + + // Distinct shapes for refraction / displacement visibility + Rectangle { + anchors.centerIn: parent + width: 64; height: 64 + color: "#f1c40f" + radius: 8 + } + Rectangle { + x: 16; y: 16 + width: 32; height: 32 + color: "#ffffff" + radius: 4 + } + Rectangle { + anchors.right: parent.right; anchors.bottom: parent.bottom + anchors.rightMargin: 16; anchors.bottomMargin: 16 + width: 48; height: 48 + color: "#e67e22" + radius: 6 + } + // Vertical bars for chromatic aberration detection + Rectangle { + x: parent.width * 0.25 - 3; y: 0 + width: 6; height: parent.height + color: "#000000" + } + Rectangle { + x: parent.width * 0.75 - 3; y: 0 + width: 6; height: parent.height + color: "#000000" + } + // Right-edge contrast target: large bezel refraction/edge material + // should stay visible along straight edges even when the corner radius + // is much smaller than the bezel width. + Rectangle { + x: 214; y: 72 + width: 8; height: 112 + color: "#ffffff" + } + Rectangle { + x: 230; y: 72 + width: 8; height: 112 + color: "#000000" + } + } + + GlassEffect { + id: glassEffect + objectName: "glassEffect" + anchors.fill: parent + source: backdrop + } +} diff --git a/tests/test_effect_glass/TestHelper.cpp b/tests/test_effect_glass/TestHelper.cpp new file mode 100644 index 000000000..e6e44c225 --- /dev/null +++ b/tests/test_effect_glass/TestHelper.cpp @@ -0,0 +1,100 @@ +// 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 "TestHelper.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + + +extern "C" { +#define static +#include +#undef static +} +TestHelper::TestHelper(QObject *parent) + : QObject(parent) + , m_server(new WServer(this)) + , m_outputCreator(new WQmlCreator(this)) + , m_outputLayout(new WQuickOutputLayout(m_server)) + , m_cursor(new WCursor(this)) +{ + m_seat = m_server->attach(); + m_seat->setCursor(m_cursor); + m_cursor->setLayout(m_outputLayout); +} + +void TestHelper::initProtocols(WOutputRenderWindow *window, QQmlEngine *qmlEngine) +{ + m_backend = m_server->attach(); + m_server->start(); + + m_renderer = WRenderHelper::createRenderer(m_backend->handle()); + if (!m_renderer) + qFatal("Failed to create wlroots renderer"); + + connect(m_backend, &WBackend::outputAdded, this, [this, qmlEngine](WOutput *output) { + auto props = qmlEngine->newObject(); + props.setProperty("waylandOutput", qmlEngine->toScriptValue(output)); + props.setProperty("layout", qmlEngine->toScriptValue(m_outputLayout)); + props.setProperty("x", qmlEngine->toScriptValue(m_outputLayout->implicitWidth())); + m_outputCreator->add(output, props); + }); + + connect(m_backend, &WBackend::outputRemoved, this, [this](WOutput *output) { + m_outputCreator->removeByOwner(output); + }); + + connect(m_backend, &WBackend::inputAdded, this, [this](WInputDevice *device) { + m_seat->attachInputDevice(device); + }); + + connect(m_backend, &WBackend::inputRemoved, this, [this](WInputDevice *device) { + m_seat->detachInputDevice(device); + }); + + m_allocator = qw_allocator::autocreate(*m_backend->handle(), *m_renderer); + m_renderer->init_wl_display(*m_server->handle()); + + m_compositor = qw_compositor::create(*m_server->handle(), 6, *m_renderer); + qw_subcompositor::create(*m_server->handle()); + + connect(window, &WOutputRenderWindow::outputViewportInitialized, this, [](WOutputViewport *viewport) { + auto qwoutput = viewport->output()->handle(); + if (!qwoutput->property("_Enabled").toBool()) { + qwoutput->setProperty("_Enabled", true); + qw_output_state newState; + if (!qwoutput->handle()->current_mode) { + auto mode = qwoutput->preferred_mode(); + if (mode) + newState.set_mode(mode); + } + newState.set_enabled(true); + if (!qwoutput->commit_state(newState)) + qCritical("commit failed on output %s", qwoutput->handle()->name); + } + }); + + window->init(m_renderer, m_allocator); + m_backend->handle()->start(); +} + +bool TestHelper::usesSoftwareRenderer() const +{ + return m_renderer && wlr_renderer_is_pixman(m_renderer->handle()); +} diff --git a/tests/test_effect_glass/TestHelper.h b/tests/test_effect_glass/TestHelper.h new file mode 100644 index 000000000..d9d0f278d --- /dev/null +++ b/tests/test_effect_glass/TestHelper.h @@ -0,0 +1,56 @@ +// 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 +#include + +WAYLIB_SERVER_BEGIN_NAMESPACE +class WServer; +class WOutputRenderWindow; +class WQuickOutputLayout; +class WCursor; +class WSeat; +class WBackend; +WAYLIB_SERVER_END_NAMESPACE + +QW_BEGIN_NAMESPACE +class qw_renderer; +class qw_allocator; +class qw_compositor; +QW_END_NAMESPACE + +WAYLIB_SERVER_USE_NAMESPACE +QW_USE_NAMESPACE + +/// Sets up a minimal wayland server with a headless wlroots backend so the +/// test gets a real OpenGL context via Mesa llvmpipe — no display required. +class TestHelper : public QObject +{ + Q_OBJECT + Q_PROPERTY(WQmlCreator *outputCreator MEMBER m_outputCreator CONSTANT) + QML_ELEMENT + QML_SINGLETON + +public: + explicit TestHelper(QObject *parent = nullptr); + + void initProtocols(WOutputRenderWindow *window, QQmlEngine *qmlEngine); + bool usesSoftwareRenderer() const; + +private: + WServer *m_server = nullptr; + WQmlCreator *m_outputCreator = nullptr; + WBackend *m_backend = nullptr; + qw_renderer *m_renderer = nullptr; + qw_allocator *m_allocator = nullptr; + qw_compositor *m_compositor = nullptr; + WQuickOutputLayout *m_outputLayout = nullptr; + WCursor *m_cursor = nullptr; + QPointer m_seat; +}; diff --git a/tests/test_effect_glass/TestWindow.qml b/tests/test_effect_glass/TestWindow.qml new file mode 100644 index 000000000..7eb04858d --- /dev/null +++ b/tests/test_effect_glass/TestWindow.qml @@ -0,0 +1,43 @@ +// 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 +import Waylib.Server +import TestGlass + +Item { + id: root + + OutputRenderWindow { + id: renderWindow + objectName: "renderWindow" + width: 256 + height: 256 + + DynamicCreatorComponent { + creator: TestHelper.outputCreator + + OutputItem { + id: outputItem + required property WaylandOutput waylandOutput + output: waylandOutput + devicePixelRatio: waylandOutput.scale + + OutputViewport { + id: outputViewport + output: waylandOutput + devicePixelRatio: parent.devicePixelRatio + anchors.centerIn: parent + width: 256 + height: 256 + } + + GlassEffectScene { + id: scene + objectName: "glassScene" + anchors.centerIn: parent + } + } + } + } +} diff --git a/tests/test_effect_glass/main.cpp b/tests/test_effect_glass/main.cpp new file mode 100644 index 000000000..d49020bac --- /dev/null +++ b/tests/test_effect_glass/main.cpp @@ -0,0 +1,759 @@ +// 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 +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "TestHelper.h" + +WAYLIB_SERVER_USE_NAMESPACE +QW_USE_NAMESPACE + +/// Liquid Glass effect test using waylib's headless backend for OpenGL. +/// +/// A custom main() sets up the waylib QPA platform with WLR_BACKENDS=headless +/// before QGuiApplication, giving us a real OpenGL context via Mesa llvmpipe +/// — no display server or xvfb-run needed. The TestGlass QML module bundles +/// GlassEffect.qml + shaders, and TestWindow.qml wraps the test scene in an +/// OutputRenderWindow + OutputItem so it renders through waylib's scene graph. +/// +/// Property tests read derived values at runtime; rendering tests use +/// grabToImage to capture and compare images. +class GlassEffectTest : public QObject +{ + Q_OBJECT + +public: + static void setGlobals(WOutputRenderWindow *w, QQuickItem *s, QQuickItem *g, TestHelper *h) + { + m_window = w; + m_scene = s; + m_glass = g; + m_helper = h; + } + +private: + static inline WOutputRenderWindow *m_window = nullptr; + static inline QQuickItem *m_scene = nullptr; + static inline QQuickItem *m_glass = nullptr; + static inline TestHelper *m_helper = nullptr; + + /// Grab an item to a QImage (asynchronous — waits for ready). + static QImage grabImage(QQuickItem *item, const QSize &size = QSize(256, 256)) + { + auto result = item->grabToImage(size); + if (!result) + return {}; + + QSignalSpy spy(result.data(), &QQuickItemGrabResult::ready); + spy.wait(5000); + return result->image(); + } + + /// Count pixels that differ between two images of the same size. + static int pixelDiffCount(const QImage &a, const QImage &b) + { + if (a.size() != b.size() || a.format() != b.format()) + return -1; + + int count = 0; + const int w = a.width(); + const int h = a.height(); + for (int y = 0; y < h; ++y) { + const auto *pa = reinterpret_cast(a.scanLine(y)); + const auto *pb = reinterpret_cast(b.scanLine(y)); + for (int x = 0; x < w; ++x) { + if (pa[x] != pb[x]) + ++count; + } + } + return count; + } + + static int colorDistance(QRgb a, QRgb b) + { + return std::abs(qRed(a) - qRed(b)) + + std::abs(qGreen(a) - qGreen(b)) + + std::abs(qBlue(a) - qBlue(b)) + + std::abs(qAlpha(a) - qAlpha(b)); + } + + static int regionDiffCount(const QImage &a, const QImage &b, const QRect ®ion, int minDistance = 0) + { + if (a.size() != b.size() || a.format() != b.format()) + return -1; + + int count = 0; + const QRect bounded = region.intersected(a.rect()); + for (int y = bounded.top(); y <= bounded.bottom(); ++y) { + const auto *pa = reinterpret_cast(a.scanLine(y)); + const auto *pb = reinterpret_cast(b.scanLine(y)); + for (int x = bounded.left(); x <= bounded.right(); ++x) { + if (colorDistance(pa[x], pb[x]) > minDistance) + ++count; + } + } + return count; + } + + static int maxDiagonalCornerDiscontinuity(const QImage &img) + { + const int w = img.width(); + int maxStep = 0; + + // Top-right has only the smooth horizontal gradient behind it in this + // fixture, so any large cross-diagonal jump is from the shader rather + // than a backdrop feature. The paired samples straddle the possible + // 45° seam where top-edge and right-edge corner math meet. + for (int d = 12; d <= 46; ++d) { + const QRgb a = img.pixel(w - 1 - d, d + 1); + const QRgb b = img.pixel(w - 2 - d, d); + maxStep = std::max(maxStep, colorDistance(a, b)); + } + + return maxStep; + } + + void setSmallRadiusLargeBezel() + { + m_glass->setProperty("highlightEnabled", false); + m_glass->setProperty("rimReflectionEnabled", false); + m_glass->setProperty("blurEnabled", false); + m_glass->setProperty("radius", 8.0); + m_glass->setProperty("bezelWidth", 52.0); + m_glass->setProperty("thickness", 120.0); + m_glass->setProperty("displacementFactor", 0.85); + m_glass->setProperty("ior", 1.45); + m_glass->setProperty("dispersion", 0.018); + m_glass->setProperty("strokeStrength", 0.0); + m_glass->setProperty("specularOpacity", 0.0); + m_glass->setProperty("edgeSaturation", 0.0); + QTest::qWait(50); + } + + static bool requiresShaderRendering(const char *testFunction) + { + static constexpr const char *renderingTests[] = { + "highlightToggleProducesDifferentRender", + "rimReflectionToggleProducesDifferentRender", + "lightAngleShiftMovesHighlight", + "radiusProducesTransparentCorners", + "largeBezelWithSmallRadiusDoesNotIntroduceCornerDiagonalSeam", + "largeBezelRemainsVisibleAlongStraightEdgesWithSmallRadius", + "zeroBlurMultiplierStillAppliesGaussianBlur", + "blurAmountAndMultiplierChangeRenderedBlurStrength", + "blurToggleProducesDifferentRender", + }; + + for (const auto *name : renderingTests) { + if (qstrcmp(testFunction, name) == 0) + return true; + } + return false; + } + + static bool shaderRenderingAvailable() + { + return m_helper && !m_helper->usesSoftwareRenderer(); + } + /// Reset glass to default property values (called before each test). + void resetGlass() + { + m_glass->setProperty("lightAngle", -135.0); + m_glass->setProperty("highlightEnabled", true); + m_glass->setProperty("rimReflectionEnabled", true); + m_glass->setProperty("blurEnabled", false); + m_glass->setProperty("blurMax", 36); + m_glass->setProperty("blurAmount", 1.0); + m_glass->setProperty("blurMultiplier", 0.0); + m_glass->setProperty("radius", 0.0); + m_glass->setProperty("bezelWidth", 18.0); + m_glass->setProperty("thickness", 90.0); + m_glass->setProperty("displacementFactor", 0.45); + m_glass->setProperty("ior", 1.42); + m_glass->setProperty("dispersion", 0.012); + m_glass->setProperty("brightness", 0.0); + m_glass->setProperty("contrast", 0.0); + m_glass->setProperty("saturation", 0.0); + m_glass->setProperty("colorization", 0.0); + m_glass->setProperty("specularOpacity", 0.6); + m_glass->setProperty("strokeWidth", 1.0); + m_glass->setProperty("strokeStrength", 1.0); + m_glass->setProperty("lightPower", 2.0); + m_glass->setProperty("edgeSaturation", 0.0); + m_glass->setProperty("reflectionOffset", 12.0); + QTest::qWait(50); + } + +private Q_SLOTS: + + void initTestCase() + { + QVERIFY(m_window); + QVERIFY(m_scene); + QVERIFY(m_glass); + } + + void init() + { + if (requiresShaderRendering(QTest::currentTestFunction()) && !shaderRenderingAvailable()) { + QSKIP("Shader rendering checks require OpenGL; the software renderer " + "does not run ShaderEffect/MultiEffect output"); + } + } + + void cleanup() + { + resetGlass(); + } + + // ── Property tests: read derived properties at runtime ───────────── + + void lightDirectionDerivesFromLightAngle() + { + // lightAngle default in GlassEffect.qml is -135° + // lightDirection = (cos(angle), sin(angle)) + const qreal angle = m_glass->property("lightAngle").toReal(); + QCOMPARE(angle, -135.0); + + const auto dir = m_glass->property("lightDirection").value(); + const qreal rad = angle * M_PI / 180.0; + QVERIFY(qAbs(dir.x() - std::cos(rad)) < 0.001); + QVERIFY(qAbs(dir.y() - std::sin(rad)) < 0.001); + + // lightAngleRadians should match + const qreal radians = m_glass->property("lightAngleRadians").toReal(); + QVERIFY(qAbs(radians - rad) < 0.001); + } + + void changingLightAngleUpdatesDerivedProperties() + { + // Set to 0° → direction should be (1, 0) + m_glass->setProperty("lightAngle", 0.0); + QTest::qWait(10); // let bindings update + const auto dir0 = m_glass->property("lightDirection").value(); + QVERIFY(qAbs(dir0.x() - 1.0) < 0.001); + QVERIFY(qAbs(dir0.y() - 0.0) < 0.001); + + // Set to 90° → direction should be (0, 1) + m_glass->setProperty("lightAngle", 90.0); + QTest::qWait(10); + const auto dir90 = m_glass->property("lightDirection").value(); + QVERIFY(qAbs(dir90.x() - 0.0) < 0.001); + QVERIFY(qAbs(dir90.y() - 1.0) < 0.001); + + // Set to -90° → direction should be (0, -1) + m_glass->setProperty("lightAngle", -90.0); + QTest::qWait(10); + const auto dirN90 = m_glass->property("lightDirection").value(); + QVERIFY(qAbs(dirN90.x() - 0.0) < 0.001); + QVERIFY(qAbs(dirN90.y() - (-1.0)) < 0.001); + } + + void highlightEnabledTogglesZeroShaderSpecular() + { + // Find the internal ShaderEffect (has objectName "glassShader") + auto *shader = m_glass->findChild("glassShader"); + QVERIFY(shader); + + // When highlightEnabled is true, shader gets non-zero specular values + m_glass->setProperty("highlightEnabled", true); + QTest::qWait(10); + + const qreal specOn = shader->property("specularOpacity").toReal(); + const qreal strokeOn = shader->property("strokeStrength").toReal(); + QVERIFY(specOn > 0.0); + QVERIFY(strokeOn > 0.0); + + // When highlightEnabled is false, both must be zero + m_glass->setProperty("highlightEnabled", false); + QTest::qWait(10); + + QCOMPARE(shader->property("specularOpacity").toReal(), 0.0); + QCOMPARE(shader->property("strokeStrength").toReal(), 0.0); + } + + void rimReflectionEnabledTogglesFloatGate() + { + auto *shader = m_glass->findChild("glassShader"); + QVERIFY(shader); + + // Enabled → 0.22 tint mix + m_glass->setProperty("rimReflectionEnabled", true); + QTest::qWait(10); + QCOMPARE(shader->property("rimReflectionStrength").toReal(), 0.22); + + // Disabled → 0.0 (pure white specular, no tint) + m_glass->setProperty("rimReflectionEnabled", false); + QTest::qWait(10); + QCOMPARE(shader->property("rimReflectionStrength").toReal(), 0.0); + } + + void multiEffectEnabledReflectsBlurAndColorParams() + { + // Default scene: blurEnabled=false, all color params=0 → false + m_glass->setProperty("blurEnabled", false); + m_glass->setProperty("brightness", 0.0); + m_glass->setProperty("contrast", 0.0); + m_glass->setProperty("saturation", 0.0); + m_glass->setProperty("colorization", 0.0); + QTest::qWait(10); + QVERIFY(!m_glass->property("multiEffectEnabled").toBool()); + + // blurEnabled → true + m_glass->setProperty("blurEnabled", true); + QTest::qWait(10); + QVERIFY(m_glass->property("multiEffectEnabled").toBool()); + + // Non-zero brightness alone → true + m_glass->setProperty("blurEnabled", false); + m_glass->setProperty("brightness", 0.05); + QTest::qWait(10); + QVERIFY(m_glass->property("multiEffectEnabled").toBool()); + + // Non-zero contrast alone → true + m_glass->setProperty("brightness", 0.0); + m_glass->setProperty("contrast", -0.12); + QTest::qWait(10); + QVERIFY(m_glass->property("multiEffectEnabled").toBool()); + + // Non-zero saturation alone → true + m_glass->setProperty("contrast", 0.0); + m_glass->setProperty("saturation", -0.15); + QTest::qWait(10); + QVERIFY(m_glass->property("multiEffectEnabled").toBool()); + + // Non-zero colorization alone → true + m_glass->setProperty("saturation", 0.0); + m_glass->setProperty("colorization", 0.12); + QTest::qWait(10); + QVERIFY(m_glass->property("multiEffectEnabled").toBool()); + } + + void dconfigFacingGlassKnobsAreRuntimeQmlProperties() + { + auto *shader = m_glass->findChild("glassShader"); + QVERIFY(shader); + + const QList propertyNames = { + "blurAmount", + "blurMultiplier", + "blurMax", + "bezelWidth", + "thickness", + "displacementFactor", + "ior", + "dispersion", + "brightness", + "contrast", + "saturation", + "colorization", + "edgeSaturation", + }; + + for (const QByteArray &name : propertyNames) { + QVERIFY2(m_glass->metaObject()->indexOfProperty(name.constData()) >= 0, + qPrintable(QStringLiteral("GlassEffect must expose %1 as a real QML property") + .arg(QString::fromLatin1(name)))); + } + + QVERIFY(m_glass->setProperty("bezelWidth", 47.0)); + QVERIFY(m_glass->setProperty("thickness", 133.0)); + QVERIFY(m_glass->setProperty("displacementFactor", 0.72)); + QVERIFY(m_glass->setProperty("ior", 1.37)); + QVERIFY(m_glass->setProperty("dispersion", 0.021)); + QVERIFY(m_glass->setProperty("blurEnabled", true)); + QVERIFY(m_glass->setProperty("blurMax", 18)); + QVERIFY(m_glass->setProperty("blurAmount", 0.75)); + QVERIFY(m_glass->setProperty("blurMultiplier", 1.5)); + QTest::qWait(10); + + QCOMPARE(shader->property("bezelWidth").toReal(), 47.0); + QCOMPARE(shader->property("thickness").toReal(), 133.0); + QCOMPARE(shader->property("displacementFactor").toReal(), 0.72); + QCOMPARE(shader->property("ior").toReal(), 1.37); + QCOMPARE(shader->property("dispersion").toReal(), 0.021); + QCOMPARE(m_glass->property("multiEffectEnabled").toBool(), true); + } + + // ── Rendering tests: grabToImage + image comparison ─────────────── + + void grabIsDeterministic() + { + const QImage img1 = grabImage(m_scene); + QVERIFY(!img1.isNull()); + + const QImage img2 = grabImage(m_scene); + QVERIFY(!img2.isNull()); + + // Same scene, same settings → identical output + QCOMPARE(img1, img2); + } + + void highlightToggleProducesDifferentRender() + { + // Set prominent glass params for visible highlights + m_glass->setProperty("highlightEnabled", true); + m_glass->setProperty("radius", 34.0); + m_glass->setProperty("bezelWidth", 16.0); + m_glass->setProperty("specularOpacity", 0.82); + m_glass->setProperty("strokeStrength", 1.5); + m_glass->setProperty("strokeWidth", 1.4); + QTest::qWait(50); + + const QImage withHighlight = grabImage(m_scene); + QVERIFY(!withHighlight.isNull()); + + m_glass->setProperty("highlightEnabled", false); + QTest::qWait(50); + + const QImage withoutHighlight = grabImage(m_scene); + QVERIFY(!withoutHighlight.isNull()); + + // Images must differ — highlights are visible + QVERIFY(withHighlight != withoutHighlight); + + // The difference should be concentrated near edges (rim), not center + const int w = withHighlight.width(); + const int h = withHighlight.height(); + int edgeDiff = 0, centerDiff = 0; + const int edgeBand = 24; // px from border + const int cx = w / 2, cy = h / 2; + const int centerRadius = 32; + + for (int y = 0; y < h; ++y) { + const auto *p1 = reinterpret_cast(withHighlight.scanLine(y)); + const auto *p2 = reinterpret_cast(withoutHighlight.scanLine(y)); + for (int x = 0; x < w; ++x) { + if (p1[x] != p2[x]) { + if (x < edgeBand || x >= w - edgeBand || y < edgeBand || y >= h - edgeBand) + ++edgeDiff; + else if (std::hypot(x - cx, y - cy) < centerRadius) + ++centerDiff; + } + } + } + // Highlights are an edge/rim phenomenon + QVERIFY2(edgeDiff > centerDiff, + qPrintable(QStringLiteral("highlight diff should be edge-dominated: edge=%1 center=%2") + .arg(edgeDiff).arg(centerDiff))); + } + + void rimReflectionToggleProducesDifferentRender() + { + // Ensure highlights are on for rim reflection to be visible + m_glass->setProperty("highlightEnabled", true); + m_glass->setProperty("radius", 34.0); + m_glass->setProperty("rimReflectionEnabled", true); + QTest::qWait(50); + + const QImage withTint = grabImage(m_scene); + QVERIFY(!withTint.isNull()); + + m_glass->setProperty("rimReflectionEnabled", false); + QTest::qWait(50); + + const QImage withoutTint = grabImage(m_scene); + QVERIFY(!withoutTint.isNull()); + + // Images must differ — rim tint affects specular color + const int diff = pixelDiffCount(withTint, withoutTint); + QVERIFY2(diff > 0, + qPrintable(QStringLiteral("rim reflection toggle must produce different output, got %1 differing pixels").arg(diff))); + } + + void lightAngleShiftMovesHighlight() + { + m_glass->setProperty("highlightEnabled", true); + m_glass->setProperty("radius", 34.0); + m_glass->setProperty("specularOpacity", 0.82); + m_glass->setProperty("strokeStrength", 1.5); + + // Use 0° and 90° — the shader's specular is symmetric (both rim + // and opposite-rim contribute equally), so 0° vs 180° would be + // identical. 0° puts highlights on left/right edges; 90° puts + // them on top/bottom edges. + m_glass->setProperty("lightAngle", 0.0); + QTest::qWait(50); + const QImage imgHorizontal = grabImage(m_scene); + QVERIFY(!imgHorizontal.isNull()); + + m_glass->setProperty("lightAngle", 90.0); + QTest::qWait(50); + const QImage imgVertical = grabImage(m_scene); + QVERIFY(!imgVertical.isNull()); + + QVERIFY(imgHorizontal != imgVertical); + + // When light is at 0°, highlights are on left/right edges. + // When light is at 90°, highlights are on top/bottom edges. + // So horizontal-edge brightness should be higher at 90°, and + // vertical-edge brightness should be higher at 0°. + const int edgeBand = 16; + + auto edgeBrightness = [](const QImage &img, int startX, int endX) { + int sum = 0; + for (int y = 0; y < img.height(); ++y) { + const auto *p = reinterpret_cast(img.scanLine(y)); + for (int x = startX; x < endX; ++x) { + const QRgb px = p[x]; + sum += qRed(px) + qGreen(px) + qBlue(px); + } + } + return sum; + }; + + auto rowBrightness = [](const QImage &img, int startY, int endY) { + int sum = 0; + for (int y = startY; y < endY; ++y) { + const auto *p = reinterpret_cast(img.scanLine(y)); + for (int x = 0; x < img.width(); ++x) { + const QRgb px = p[x]; + sum += qRed(px) + qGreen(px) + qBlue(px); + } + } + return sum; + }; + + const int w = imgHorizontal.width(); + const int h = imgHorizontal.height(); + + // Light at 0° → vertical edges (left/right) brighter + const int vertBright_0deg = edgeBrightness(imgHorizontal, 0, edgeBand) + + edgeBrightness(imgHorizontal, w - edgeBand, w); + const int vertBright_90deg = edgeBrightness(imgVertical, 0, edgeBand) + + edgeBrightness(imgVertical, w - edgeBand, w); + QVERIFY2(vertBright_0deg > vertBright_90deg, + qPrintable(QStringLiteral("vertical edges should be brighter at 0°: 0deg=%1 90deg=%2") + .arg(vertBright_0deg).arg(vertBright_90deg))); + + // Light at 90° → horizontal edges (top/bottom) brighter + const int horizBright_90deg = rowBrightness(imgVertical, 0, edgeBand) + + rowBrightness(imgVertical, h - edgeBand, h); + const int horizBright_0deg = rowBrightness(imgHorizontal, 0, edgeBand) + + rowBrightness(imgHorizontal, h - edgeBand, h); + QVERIFY2(horizBright_90deg > horizBright_0deg, + qPrintable(QStringLiteral("horizontal edges should be brighter at 90°: 90deg=%1 0deg=%2") + .arg(horizBright_90deg).arg(horizBright_0deg))); + } + + void radiusProducesTransparentCorners() + { + // Large radius for clearly transparent corners + m_glass->setProperty("radius", 60.0); + m_glass->setProperty("highlightEnabled", false); + QTest::qWait(50); + + // Grab the glass item directly (not root) so the visible backdrop + // doesn't fill the transparent corners. + const QImage img = grabImage(m_glass); + QVERIFY(!img.isNull()); + + // Corner pixels should be fully transparent (alpha = 0) + const int w = img.width(); + const int h = img.height(); + const int offset = 2; // a few pixels in from the absolute corner + + const QList cornerPixels = { + {offset, offset}, // top-left + {w - 1 - offset, offset}, // top-right + {offset, h - 1 - offset}, // bottom-left + {w - 1 - offset, h - 1 - offset}, // bottom-right + }; + + for (const auto &pt : cornerPixels) { + const QRgb px = img.pixel(pt.x(), pt.y()); + QVERIFY2(qAlpha(px) == 0, + qPrintable(QStringLiteral("corner pixel (%1,%2) should be transparent, got alpha=%3") + .arg(pt.x()).arg(pt.y()).arg(qAlpha(px)))); + } + + // Center pixel should be opaque + const QRgb centerPx = img.pixel(w / 2, h / 2); + QVERIFY2(qAlpha(centerPx) == 255, + qPrintable(QStringLiteral("center pixel should be opaque, got alpha=%1") + .arg(qAlpha(centerPx)))); + } + + void largeBezelWithSmallRadiusDoesNotIntroduceCornerDiagonalSeam() + { + setSmallRadiusLargeBezel(); + + const QImage img = grabImage(m_scene); + QVERIFY(!img.isNull()); + + const int seamStep = maxDiagonalCornerDiscontinuity(img); + QVERIFY2(seamStep < 96, + qPrintable(QStringLiteral("small-radius/large-bezel corner has a sharp diagonal seam: max cross-diagonal step=%1") + .arg(seamStep))); + } + + void largeBezelRemainsVisibleAlongStraightEdgesWithSmallRadius() + { + setSmallRadiusLargeBezel(); + m_glass->setProperty("edgeSaturation", 1.5); + QTest::qWait(50); + + const QImage largeBezel = grabImage(m_scene); + QVERIFY(!largeBezel.isNull()); + + m_glass->setProperty("bezelWidth", 1.0); + QTest::qWait(50); + + const QImage tinyBezel = grabImage(m_scene); + QVERIFY(!tinyBezel.isNull()); + + // Far enough from the corners that radius must not suppress the + // straight-edge bezel, and far enough from the boundary that a shader + // clamping bezel width to radius would leave the region unchanged. + // Use the right edge: its horizontal refraction crosses the fixture's + // horizontal gradient, so a live bezel changes observable pixels. + const QRect rightStraightEdge(212, 72, 24, 112); + const int activePixels = regionDiffCount(largeBezel, tinyBezel, rightStraightEdge, 8); + QVERIFY2(activePixels > rightStraightEdge.width() * rightStraightEdge.height() / 3, + qPrintable(QStringLiteral("large straight-edge bezel should remain active beyond the small radius: changedPixels=%1 regionPixels=%2") + .arg(activePixels) + .arg(rightStraightEdge.width() * rightStraightEdge.height()))); + } + + void zeroBlurMultiplierStillAppliesGaussianBlur() + { + m_glass->setProperty("highlightEnabled", false); + m_glass->setProperty("rimReflectionEnabled", false); + m_glass->setProperty("blurEnabled", true); + m_glass->setProperty("blurMax", 48); + m_glass->setProperty("blurAmount", 1.0); + QVERIFY(m_glass->setProperty("blurMultiplier", 0.0)); + QTest::qWait(50); + + const QImage defaultQualityBlur = grabImage(m_scene); + QVERIFY(!defaultQualityBlur.isNull()); + + m_glass->setProperty("blurEnabled", false); + QTest::qWait(50); + + const QImage withoutBlur = grabImage(m_scene); + QVERIFY(!withoutBlur.isNull()); + + const QRect centerContrastFeature(92, 92, 72, 72); + const int changedPixels = regionDiffCount(defaultQualityBlur, withoutBlur, centerContrastFeature, 4); + QVERIFY2(changedPixels > centerContrastFeature.width() * centerContrastFeature.height() / 5, + qPrintable(QStringLiteral("blurMultiplier=0 must keep blur active instead of disabling it: changedPixels=%1 regionPixels=%2") + .arg(changedPixels) + .arg(centerContrastFeature.width() * centerContrastFeature.height()))); + } + + void blurAmountAndMultiplierChangeRenderedBlurStrength() + { + m_glass->setProperty("highlightEnabled", false); + m_glass->setProperty("blurEnabled", true); + m_glass->setProperty("blurMax", 48); + m_glass->setProperty("radius", 34.0); + QVERIFY2(m_glass->setProperty("blurAmount", 0.15), + "GlassEffect must expose blurAmount as a runtime QML property"); + QVERIFY2(m_glass->setProperty("blurMultiplier", 0.5), + "GlassEffect must expose blurMultiplier as a runtime QML property"); + QTest::qWait(50); + + const QImage weakBlur = grabImage(m_scene); + QVERIFY(!weakBlur.isNull()); + + QVERIFY(m_glass->setProperty("blurAmount", 1.0)); + QVERIFY(m_glass->setProperty("blurMultiplier", 2.0)); + QTest::qWait(50); + + const QImage strongBlur = grabImage(m_scene); + QVERIFY(!strongBlur.isNull()); + + const QRect centerContrastFeature(92, 92, 72, 72); + const int changedPixels = regionDiffCount(weakBlur, strongBlur, centerContrastFeature, 4); + QVERIFY2(changedPixels > centerContrastFeature.width() * centerContrastFeature.height() / 5, + qPrintable(QStringLiteral("blurAmount/blurMultiplier must visibly change the sampled backdrop blur: changedPixels=%1 regionPixels=%2") + .arg(changedPixels) + .arg(centerContrastFeature.width() * centerContrastFeature.height()))); + } + void blurToggleProducesDifferentRender() + { + m_glass->setProperty("blurEnabled", true); + m_glass->setProperty("blurMax", 36); + m_glass->setProperty("radius", 34.0); + QTest::qWait(50); + + const QImage withBlur = grabImage(m_scene); + QVERIFY(!withBlur.isNull()); + + m_glass->setProperty("blurEnabled", false); + QTest::qWait(50); + + const QImage withoutBlur = grabImage(m_scene); + QVERIFY(!withoutBlur.isNull()); + + QVERIFY(withBlur != withoutBlur); + } +}; + +int main(int argc, char *argv[]) +{ + // Headless wlroots backend — no display server needed. + qputenv("WLR_BACKENDS", "headless"); + + qw_log::init(); + WServer::initializeQPA(); + + // Probe the graphics API that can create a wlroots renderer in this + // environment. CI containers without a DRM render node fall back to + // Software/pixman; rendering-only tests skip in that mode. + WRenderHelper::setupRendererBackend(); + + QGuiApplication app(argc, argv); + QQmlApplicationEngine engine; + + engine.loadFromModule("TestGlass", "TestWindow"); + if (engine.rootObjects().isEmpty()) + return 1; + + auto *root = engine.rootObjects().first(); + auto *window = root->findChild("renderWindow"); + Q_ASSERT(window); + + auto *helper = engine.singletonInstance("TestGlass", "TestHelper"); + Q_ASSERT(helper); + helper->initProtocols(window, &engine); + window->setVisible(true); // QQuickItem::grabToImage requires isVisible + + // Wait for the headless output to be created and the scene to be ready. + QSignalSpy initSpy(window, &WOutputRenderWindow::outputViewportInitialized); + if (initSpy.isEmpty()) + initSpy.wait(5000); + QTest::qWait(500); // let the scene graph settle + + auto *scene = window->findChild("glassScene"); + Q_ASSERT(scene); + auto *glass = window->findChild("glassEffect"); + Q_ASSERT(glass); + + GlassEffectTest::setGlobals(window, scene, glass, helper); + + GlassEffectTest test; + int result = QTest::qExec(&test, argc, argv); + // wlroots objects (renderer, allocator, compositor) crash during normal + // destructor unwinding — skip cleanup and exit immediately. + std::_Exit(result); +} + +#include "main.moc" diff --git a/waylib/src/server/qtquick/wrenderbufferblitter.cpp b/waylib/src/server/qtquick/wrenderbufferblitter.cpp index f36a39a19..9443bd646 100644 --- a/waylib/src/server/qtquick/wrenderbufferblitter.cpp +++ b/waylib/src/server/qtquick/wrenderbufferblitter.cpp @@ -25,9 +25,39 @@ class Q_DECL_HIDDEN BlitTextureProvider : public QSGTextureProvider { } inline void setTexture(QSGTexture *tex) { m_texture = tex; + updateTextureOptions(); + } + + inline void setSmooth(bool smooth) { + if (m_smooth == smooth) + return; + + m_smooth = smooth; + updateTextureOptions(); + Q_EMIT textureChanged(); + } + + inline void setAntialiasing(bool antialiasing) { + if (m_antialiasing == antialiasing) + return; + + m_antialiasing = antialiasing; + updateTextureOptions(); + Q_EMIT textureChanged(); } private: + inline void updateTextureOptions() { + if (!m_texture) + return; + + m_texture->setFiltering(m_smooth ? QSGTexture::Linear : QSGTexture::Nearest); + m_texture->setAnisotropyLevel(m_antialiasing ? QSGTexture::Anisotropy4x : QSGTexture::AnisotropyNone); + } + + bool m_antialiasing = false; + bool m_smooth = false; + QSGTexture *m_texture = nullptr; }; @@ -158,8 +188,12 @@ BlitTextureProvider *WRenderBufferBlitterPrivate::ensureTextureProvider() const if (Q_LIKELY(tp)) return tp; + auto q = const_cast(q_func()); tp = new BlitTextureProvider(); - + tp->setSmooth(q->smooth()); + tp->setAntialiasing(q->antialiasing()); + QObject::connect(q, &QQuickItem::smoothChanged, tp, &BlitTextureProvider::setSmooth); + QObject::connect(q, &QQuickItem::antialiasingChanged, tp, &BlitTextureProvider::setAntialiasing); if (!content->offscreen()) tp->connect(tp, &BlitTextureProvider::textureChanged, content, &Content::update, Qt::UniqueConnection);