diff --git a/.gitignore b/.gitignore index 51c61dc..ae97f4c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ __pycache__ gschemas.compiled out/ *.po~ +kwin/src/xrdriveripc/xrdriveripc.py diff --git a/bin/breezy_gnome_setup b/bin/breezy_gnome_setup index f93c496..274bad5 100755 --- a/bin/breezy_gnome_setup +++ b/bin/breezy_gnome_setup @@ -59,7 +59,7 @@ if [ -z "$binary_path_arg" ] then # download and unzip the binary echo "Downloading to: ${tmp_dir}/$FILE_NAME" - curl -L -O $binary_download_url + curl -L "$binary_download_url" > "$FILE_NAME" else FILE_NAME=$(basename $binary_path_arg) if [[ "$binary_path_arg" = /* ]]; then diff --git a/bin/breezy_kwin_setup b/bin/breezy_kwin_setup new file mode 100755 index 0000000..ac35d7e --- /dev/null +++ b/bin/breezy_kwin_setup @@ -0,0 +1,79 @@ +#!/usr/bin/env bash + +# This setup script should do the minimum work required to download the release package, unzip it, and kick off the +# setup script contained within. + +# exit when any command fails +set -e + +if [ "$(id -u)" == "0" ]; then + echo "This script must not be run as root" 1>&2 + exit 1 +fi + +check_command() { + if ! command -v "$1" &>/dev/null; then + echo "Please install \"$1\" and make sure it's available in your \$PATH, then rerun the setup." + exit 1 + fi +} + +check_command "curl" + +ARCH=$(uname -m) +if [ -f /etc/os-release ]; then + . /etc/os-release + if [ "$ID" == "steamos" ]; then + ARCH="steamos" + fi +fi +FILE_NAME="breezyKWin-$ARCH.tar.gz" +LATEST_RELEASE=$(curl -s "https://api.github.com/repos/wheaney/breezy-desktop/releases/latest") +echo "Performing setup for KWin ($ARCH)" + +start_dir=$(pwd) + +# create temp directory +tmp_dir=$(mktemp -d -t breezy-kwin-XXXXXXXXXX) +pushd $tmp_dir > /dev/null +echo "Created temp directory: ${tmp_dir}" + +binary_download_url="https://github.com/wheaney/breezy-desktop/releases/latest/download/$FILE_NAME" +if [ "$1" = "-v" ] +then + metrics_version="$2" + binary_path_arg="$3" +elif [ "$1" = "--tag" ] && [ -n "$2" ] +then + binary_download_url="https://github.com/wheaney/breezy-desktop/releases/download/$2/$FILE_NAME" +else + binary_path_arg="$1" +fi + +if [ -z "$binary_path_arg" ] +then + # download and unzip the binary + echo "Downloading to: ${tmp_dir}/$FILE_NAME" + curl -L "$binary_download_url" > "$FILE_NAME" +else + FILE_NAME=$(basename $binary_path_arg) + if [[ "$binary_path_arg" = /* ]]; then + abs_path="$binary_path_arg" + else + # Convert relative path to absolute path + abs_path=$(realpath "$start_dir/$binary_path_arg") + fi + cp $abs_path $tmp_dir +fi + +echo "Extracting to: ${tmp_dir}/breezy_kwin" +tar -xf $FILE_NAME + +pushd breezy_kwin > /dev/null + +# run the setup script that comes with this release +bin/setup $metrics_version + +echo "Deleting temp directory: ${tmp_dir}" +rm -rf $tmp_dir +cd "$(dirs -l -0)" && dirs -c diff --git a/bin/breezy_vulkan_setup b/bin/breezy_vulkan_setup index 464e785..bac0377 100755 --- a/bin/breezy_vulkan_setup +++ b/bin/breezy_vulkan_setup @@ -39,10 +39,11 @@ fi if [ -z "$binary_path_arg" ] then - # download and unzip the latest driver - echo "Downloading to: ${tmp_dir}/breezyVulkan-$ARCH.tar.gz" - curl -L -O $binary_download_url + # download and unzip the binary binary_path_arg="breezyVulkan-$ARCH.tar.gz" + echo "Downloading to: ${tmp_dir}/$binary_path_arg" + + curl -L "$binary_download_url" > "$binary_path_arg" else if [[ "$binary_path_arg" = /* ]]; then abs_path="$binary_path_arg" diff --git a/bin/package_kwin b/bin/package_kwin new file mode 100755 index 0000000..b30f076 --- /dev/null +++ b/bin/package_kwin @@ -0,0 +1,82 @@ +#!/usr/bin/env bash + +# exit when any command fails +set -e + +ARCH=${ARCH:-$(uname -m)} +BUILD_ARCH=$ARCH +if [ -n "${STEAMOS+x}" ]; then + ARCH="x86_64" + BUILD_ARCH="steamos" +fi + +# https://stackoverflow.com/a/246128 +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd) +ROOT_DIR=$(realpath $SCRIPT_DIR/..) +XR_DRIVER_DIR=$ROOT_DIR/modules/XRLinuxDriver + +echo "Building Breezy KWin for $BUILD_ARCH" +KWIN_DIR=$ROOT_DIR/kwin +BUILD_FILE_NAME=breezyKWin-$BUILD_ARCH.tar.gz + +TMP_DIR=$(mktemp -d -t breezy-kwin-XXXXXXXXXX) +PACKAGE_DIR=$TMP_DIR/breezy_kwin +mkdir -p $PACKAGE_DIR + +source $XR_DRIVER_DIR/bin/inject_ua + +# copy vulkan setup scripts and configs +mkdir -p $PACKAGE_DIR/bin +copy_and_inject_ua "$XR_DRIVER_DIR/bin/ua.sh" "$PACKAGE_DIR/bin" "$KWIN_DIR/bin/setup" "$KWIN_DIR/bin/breezy_kwin_uninstall" + +XR_DRIVER_BINARY=$XR_DRIVER_DIR/out/xrDriver-$ARCH.tar.gz + +if [ ! -e "$XR_DRIVER_BINARY" ] || [ "$1" == "--rebuild-driver" ] || [ "$1" == "--rebuild-all" ]; then + # if a file exists at custom_banner_config.yml, copy it to the xrealAirLinuxDriver directory + if [ -e "$VULKAN_DIR/custom_banner_config.yml" ]; then + cp $VULKAN_DIR/custom_banner_config.yml $XR_DRIVER_DIR + fi + + pushd $XR_DRIVER_DIR + + # strange issue where the base library produces a .so file if the build is not cleaned + rm -rf build/ + + docker-build/init.sh + docker-build/run-build.sh $ARCH + popd +fi + +XR_DRIVER_TMP_DIR=$(mktemp -d -t xr-driver-XXXXXXXXXX) +pushd $XR_DRIVER_TMP_DIR +cp $XR_DRIVER_BINARY $XR_DRIVER_TMP_DIR/xrDriver.tar.gz +tar -xf $XR_DRIVER_TMP_DIR/xrDriver.tar.gz + +XR_DRIVER_MANIFEST_LINE=$(sha256sum xr_driver/manifest) +popd +rm -rf $XR_DRIVER_TMP_DIR + +cp $XR_DRIVER_BINARY $PACKAGE_DIR/xrDriver.tar.gz +cp $XR_DRIVER_DIR/bin/xr_driver_setup $PACKAGE_DIR/bin + +# alternative to symlinking, since the Docker build can't resolve to the parent directory +# this file is in .gitignore so it doesn't get duplicated +cp ui/modules/PyXRLinuxDriverIPC/xrdriveripc.py $KWIN_DIR/src/xrdriveripc/xrdriveripc.py + +pushd $KWIN_DIR +docker-build/init.sh +docker-build/run-build.sh $BUILD_ARCH +popd +cp $KWIN_DIR/out/breezyKWinPlugin-$BUILD_ARCH.tar.gz $PACKAGE_DIR/breezyKWinPlugin.tar.gz + +pushd $TMP_DIR +tar -zcvf $BUILD_FILE_NAME breezy_kwin +popd + +mkdir -p out +if [ -e "out/$BUILD_FILE_NAME" ]; then + rm out/$BUILD_FILE_NAME +fi +cp $TMP_DIR/$BUILD_FILE_NAME out + +rm -rf $TMP_DIR \ No newline at end of file diff --git a/gnome/src/math.js b/gnome/src/math.js index 0eebd6c..2a1c2eb 100644 --- a/gnome/src/math.js +++ b/gnome/src/math.js @@ -14,8 +14,8 @@ export function diagonalToCrossFOVs(diagonalFOVRadians, aspectRatio) { // then convert back to spherical FOV return { diagonal: diagonalFOVRadians, - horizontal: 2 * Math.atan(Math.tan(flatHorizontalFOV / 2)), - vertical: 2 * Math.atan(Math.tan(flatVerticalFOV / 2)) + horizontal: 2 * Math.atan(flatHorizontalFOV / 2), + vertical: 2 * Math.atan(flatVerticalFOV / 2) } } diff --git a/kwin/CMakeLists.txt b/kwin/CMakeLists.txt new file mode 100644 index 0000000..c3e8411 --- /dev/null +++ b/kwin/CMakeLists.txt @@ -0,0 +1,55 @@ +cmake_minimum_required(VERSION 3.20) + +project(breezy_desktop VERSION 0.0.1 LANGUAGES CXX) + +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +include(cmake/default-vars.cmake) + +find_package(ECM "5.100" REQUIRED NO_MODULE) +set(CMAKE_MODULE_PATH + ${CMAKE_MODULE_PATH} + ${ECM_MODULE_PATH} + ${ECM_KDE_MODULE_DIR} +) +include(cmake/qtversion.cmake) +include(FeatureSummary) +include(KDEInstallDirs) +include(KDECMakeSettings) +include(KDECompilerSettings NO_POLICY_SCOPE) + +include(cmake/qtversion.cmake) + +# required frameworks by Core +find_package(KF${QT_MAJOR_VERSION} ${KF_MIN_VERSION} REQUIRED COMPONENTS + Config + ConfigWidgets + CoreAddons + GlobalAccel + I18n + KCMUtils + WindowSystem + XmlGui +) + +if(${QT_MAJOR_VERSION} EQUAL 6) + find_package(KF${QT_MAJOR_VERSION} ${KF_MIN_VERSION} REQUIRED COMPONENTS KCMUtils) + find_package(KWin REQUIRED COMPONENTS kwineffects) + message(STATUS "Found KWin Version: ${KWin_VERSION}") +endif () +include(cmake/info.cmake) +find_package(epoxy REQUIRED) +find_package(XCB REQUIRED COMPONENTS XCB) +find_package(KWinDBusInterface CONFIG REQUIRED) + +add_subdirectory(src) +ki18n_install(po) + +feature_summary(WHAT ALL FATAL_ON_MISSING_REQUIRED_PACKAGES) + +include(cmake/test.cmake) +include(cmake/pack.cmake) diff --git a/kwin/bin/breezy_kwin_uninstall b/kwin/bin/breezy_kwin_uninstall new file mode 100755 index 0000000..b14d6de --- /dev/null +++ b/kwin/bin/breezy_kwin_uninstall @@ -0,0 +1,56 @@ +#!/usr/bin/env bash + +for_install=0 +if [[ -n "$1" ]] && [[ "$1" == "--for-install" ]]; then + for_install=1 +fi + +# we don't want the uninstall script to be able to cause a failure if being triggered by the setup script +[ "$for_install" -eq 0 ] && set -e + +USER_HOME=$(realpath ~) +if [ "$for_install" -eq 0 ]; then + UA_EVENT_NAME="breezy_kwin_uninstall" + UA_CLIENT_ID="BreezyKWin" + #INJECT_UA_CALL +fi + +if [ -z "$XDG_BIN_HOME" ]; then + XDG_BIN_HOME="$USER_HOME/.local/bin" +fi + +if [ -z "$XDG_DATA_HOME" ]; then + XDG_DATA_HOME="$USER_HOME/.local/share" +fi + +if [ -z "$LIB_HOME" ]; then + LIB_HOME="$USER_HOME/.local/lib" +fi + +EFFECT_ID="breezy_desktop" +EFFECT_DIR="$XDG_DATA_HOME/kwin/effects/$EFFECT_ID" +PLUGIN_SO="$LIB_HOME/qt6/plugins/kwin/effects/plugins/${EFFECT_ID}.so" +CONFIG_SO="$LIB_HOME/qt6/plugins/kwin/effects/configs/${EFFECT_ID}_config.so" + +if [[ -d "$EFFECT_DIR" ]]; then + [ "$for_install" -eq 0 ] && echo "Removing $EFFECT_DIR and its contents" + $SUDO rm -rf "$EFFECT_DIR" +fi + +if [[ -f "$PLUGIN_SO" ]]; then + [ "$for_install" -eq 0 ] && echo "Removing $PLUGIN_SO" + $SUDO rm -f "$PLUGIN_SO" +fi + +if [[ -f "$CONFIG_SO" ]]; then + [ "$for_install" -eq 0 ] && echo "Removing $CONFIG_SO" + $SUDO rm -f "$CONFIG_SO" +fi + +if [[ -e "$XDG_BIN_HOME/xr_driver_uninstall" && "$for_install" -eq 0 ]]; then + echo "Uninstalling XRLinuxDriver" + sudo "$XDG_BIN_HOME/xr_driver_uninstall" +fi + +# this script is self-deleting, leave this as the last command +rm -f $XDG_BIN_HOME/breezy_kwin_uninstall \ No newline at end of file diff --git a/kwin/bin/package_kwin_plugin b/kwin/bin/package_kwin_plugin new file mode 100755 index 0000000..a5f4346 --- /dev/null +++ b/kwin/bin/package_kwin_plugin @@ -0,0 +1,23 @@ +#!/usr/bin/env bash + +# exit when any command fails +set -e + +ARCH=${ARCH:-$(uname -m)} +if [ -n "${STEAMOS+x}" ]; then + ARCH="steamos" +fi +echo "Building Breezy KWin plugin for $ARCH" + +BUILD_PATH=build +rm -rf $BUILD_PATH +mkdir $BUILD_PATH + +pushd $BUILD_PATH +cmake .. +make +cpack -G TGZ +popd + +mkdir -p out +cp $BUILD_PATH/breezy_desktop.tar.gz out/breezyKWinPlugin-$ARCH.tar.gz \ No newline at end of file diff --git a/kwin/bin/setup b/kwin/bin/setup new file mode 100755 index 0000000..23a532d --- /dev/null +++ b/kwin/bin/setup @@ -0,0 +1,96 @@ +#!/usr/bin/env bash + +set -e + +if [ "$XDG_SESSION_TYPE" != "wayland" ]; then + printf "\033[1;33mWARNING:\033[0m Windowing system is %s\n" "$XDG_SESSION_TYPE" + printf "\033[1;33mWARNING:\033[0m Virtual display functionality requires Wayland\n" +fi + +USER_HOME=$(realpath ~) + +if [ -z "$XDG_BIN_HOME" ]; then + XDG_BIN_HOME="$USER_HOME/.local/bin" +fi + +if [ -d "$XDG_BIN_HOME" ]; then + # check ownership and permissions before doing chown and chmod + XDG_BIN_USER=$(stat -c %U $XDG_BIN_HOME) + XDG_BIN_GROUP=$(stat -c %G $XDG_BIN_HOME) + + USER=$(whoami) + GROUP=$(id -gn) + + if [ "$XDG_BIN_USER" != "$USER" ] || [ "$XDG_BIN_GROUP" != "$GROUP" ]; then + echo "Fixing ownership and permissions of $XDG_BIN_HOME" + sudo chown -R $USER:$GROUP $XDG_BIN_HOME + sudo chmod -R 700 $XDG_BIN_HOME + fi +fi + +UA_EVENT_NAME="breezy_kwin_install" +if [ -e "$XDG_BIN_HOME/breezy_kwin_uninstall" ]; then + echo "Cleaning up the previous installation" + + # ` || true` will ensure that this can't cause a failure, even with `set -e` + $XDG_BIN_HOME/breezy_kwin_uninstall --for-install || true + + UA_EVENT_NAME="breezy_kwin_update" +fi + +UA_CLIENT_ID="BreezyKWin" +UA_EVENT_VERSION="$1" +#INJECT_UA_CALL + +tar -xf $(pwd)/breezyKWinPlugin.tar.gz +pushd breezy_desktop/usr > /dev/null + +echo "Copying KWin plugin files to $USER_HOME/.local/{lib,share}" + +# locate the lib path that ends with qt6/plugins (handles multiarch dirs) +QT_PLUGIN_DIR_RELATIVE=$(find lib -type d -path '*/qt6/plugins' -print -quit 2>/dev/null || true) +if [ -z "$QT_PLUGIN_DIR_RELATIVE" ]; then + QT_PLUGIN_DIR_RELATIVE="lib/qt6/plugins" +fi + +# directory structure matches XDG, so just recursive copy +chmod -R 755 . +cp -r . "$USER_HOME/.local/" + +popd > /dev/null + +mkdir -p $XDG_BIN_HOME +cp bin/breezy_kwin_uninstall $XDG_BIN_HOME + +# Install QT_PLUGIN_PATH snippet into ~/.bash_profile if not present +BASH_PROFILE="$HOME/.bash_profile" +QT_PLUGIN_DIR="$HOME/.local/$QT_PLUGIN_DIR_RELATIVE" +QT_PLUGIN_EXPORT="export QT_PLUGIN_PATH=\"$QT_PLUGIN_DIR\"" +if [[ ! -f "$BASH_PROFILE" ]] || ! grep -Fq "$QT_PLUGIN_EXPORT" "$BASH_PROFILE" 2>/dev/null; then + echo "Adding QT_PLUGIN_PATH to $BASH_PROFILE" + mkdir -p "$(dirname "$BASH_PROFILE")" + cat >> "$BASH_PROFILE" < files + +if [[ "$1" == "--init" || ! $(docker buildx inspect breezykwinbuilder &>/dev/null; echo $?) -eq 0 ]]; then + # start fresh + echo "Creating new docker builder instance" + docker buildx rm breezykwinbuilder 2>/dev/null || true + docker buildx create --use --name breezykwinbuilder --driver docker-container --driver-opt image=moby/buildkit:latest +else + echo "Using existing docker builder instance" + docker buildx use breezykwinbuilder +fi + +echo "Building docker image" +docker buildx build --platform linux/amd64 -f ./docker-build/Dockerfile -t "breezy-kwin:amd64" --load . +# docker buildx build --platform linux/arm64 -f ./docker-build/Dockerfile -t "breezy-kwin:arm64" --load . +docker buildx build --platform linux/amd64 -f ./docker-build/Dockerfile.steamos -t "breezy-kwin-steamos:amd64" --load . \ No newline at end of file diff --git a/kwin/docker-build/run-build.sh b/kwin/docker-build/run-build.sh new file mode 100755 index 0000000..ff19c84 --- /dev/null +++ b/kwin/docker-build/run-build.sh @@ -0,0 +1,28 @@ +#!/bin/bash + +set -e + +USER=${SUDO_USER:-$USER} +GROUP=$(id -gn $USER) + +# Run containers for each architecture +if [[ "$1" == "x86_64" || -z "$1" ]]; then + sudo rm -rf build/ + docker run --rm -t -v ./:/source --platform linux/amd64 "breezy-kwin:amd64" + sudo chown -R $USER:$GROUP out/ +fi + +if [[ "$1" == "aarch64" || -z "$1" ]]; then + sudo rm -rf build/ + docker run --rm -t -v ./:/source --platform linux/arm64 "breezy-kwin:arm64" + sudo chown -R $USER:$GROUP out/ +fi + +if [[ "$1" == "steamos" || -z "$1" ]]; then + sudo rm -rf build/ + docker run --rm -t -v ./:/source --platform linux/amd64 "breezy-kwin-steamos:amd64" + sudo chown -R $USER:$GROUP out/ +fi + +# build directory structure is all owned by root because of docker, delete it all now +sudo chown -R $USER:$GROUP build/ \ No newline at end of file diff --git a/kwin/src/CMakeLists.txt b/kwin/src/CMakeLists.txt new file mode 100644 index 0000000..619ee0b --- /dev/null +++ b/kwin/src/CMakeLists.txt @@ -0,0 +1,61 @@ +add_subdirectory(xrdriveripc) +add_subdirectory(kcm) + +kcoreaddons_add_plugin(breezy_desktop INSTALL_NAMESPACE "kwin/effects/plugins/") +target_sources(breezy_desktop PRIVATE + breezydesktopeffect.cpp + main.cpp +) +kconfig_add_kcfg_files(breezy_desktop breezydesktopconfig.kcfgc) + +# Split KWin version into numeric components (major, minor, patch) +string(REGEX MATCHALL "[0-9]+" KWIN_VERSION_COMPONENTS "${KWin_VERSION}") + +# defaults +set(KWIN_VERSION_MAJOR 0) +set(KWIN_VERSION_MINOR 0) +set(KWIN_VERSION_PATCH 0) + +list(LENGTH KWIN_VERSION_COMPONENTS _kwin_version_len) +if(_kwin_version_len GREATER 0) + list(GET KWIN_VERSION_COMPONENTS 0 KWIN_VERSION_MAJOR) +endif() +if(_kwin_version_len GREATER 1) + list(GET KWIN_VERSION_COMPONENTS 1 KWIN_VERSION_MINOR) +endif() +if(_kwin_version_len GREATER 2) + list(GET KWIN_VERSION_COMPONENTS 2 KWIN_VERSION_PATCH) +endif() + +# optional: a single encoded integer (major*10000 + minor*100 + patch) +math(EXPR KWIN_VERSION_ENCODED "${KWIN_VERSION_MAJOR} * 10000 + ${KWIN_VERSION_MINOR} * 100 + ${KWIN_VERSION_PATCH}") + +# Export as compile definitions. Keep the original string macro as well. +target_compile_definitions(breezy_desktop PRIVATE + KWIN_VERSION_STR=\"${KWin_VERSION}\" + KWIN_VERSION_MAJOR=${KWIN_VERSION_MAJOR} + KWIN_VERSION_MINOR=${KWIN_VERSION_MINOR} + KWIN_VERSION_PATCH=${KWIN_VERSION_PATCH} + KWIN_VERSION_ENCODED=${KWIN_VERSION_ENCODED} +) +target_include_directories(breezy_desktop PRIVATE /usr/include/kwin) +target_include_directories(breezy_desktop PRIVATE xrdriveripc) +target_link_libraries(breezy_desktop + Qt6::Core + Qt6::Gui + Qt6::Quick + + KF6::ConfigCore + KF6::ConfigGui + KF6::CoreAddons + KF6::GlobalAccel + KF6::I18n + KF6::WindowSystem + + KWin::kwin + + xr_driver_ipc +) + + +install(DIRECTORY qml DESTINATION ${KDE_INSTALL_DATADIR}/kwin/effects/breezy_desktop) \ No newline at end of file diff --git a/kwin/src/breezydesktopconfig.kcfg b/kwin/src/breezydesktopconfig.kcfg new file mode 100644 index 0000000..8d0824d --- /dev/null +++ b/kwin/src/breezydesktopconfig.kcfg @@ -0,0 +1,33 @@ + + + + + + 85 + 25 + 225 + + + + 105 + 25 + 225 + + + + false + + Enable zooming in on the focused display. + + + 0 + 0 + 100 + + How far apart the displays are visually (not logically) + + + diff --git a/kwin/src/breezydesktopconfig.kcfgc b/kwin/src/breezydesktopconfig.kcfgc new file mode 100644 index 0000000..f1666fe --- /dev/null +++ b/kwin/src/breezydesktopconfig.kcfgc @@ -0,0 +1,5 @@ +File=breezydesktopconfig.kcfg +ClassName=BreezyDesktopConfig +Singleton=true +Mutators=true +Notifiers=true \ No newline at end of file diff --git a/kwin/src/breezydesktopeffect.cpp b/kwin/src/breezydesktopeffect.cpp new file mode 100644 index 0000000..6288baa --- /dev/null +++ b/kwin/src/breezydesktopeffect.cpp @@ -0,0 +1,533 @@ + +#include "core/rendertarget.h" +#include "core/renderviewport.h" +#include "kcm/shortcuts.h" +#include "breezydesktopeffect.h" +#include "breezydesktopconfig.h" +#include "effect/effect.h" +#include "effect/effecthandler.h" +#include "opengl/glutils.h" +#include "xrdriveripc.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +Q_LOGGING_CATEGORY(KWIN_XR, "kwin.xr") + +namespace DataView +{ + const QString SHM_DIR = QStringLiteral("/dev/shm"); + const QString SHM_PATH = SHM_DIR + QStringLiteral("/breezy_desktop_imu"); + + // Helper constants and functions for shared memory buffer offsets + constexpr int UINT8_SIZE = sizeof(uint8_t); + constexpr int BOOL_SIZE = UINT8_SIZE; + constexpr int UINT_SIZE = sizeof(uint32_t); + constexpr int FLOAT_SIZE = sizeof(float); + + // DataView info: [offset, size, count] + constexpr int OFFSET_INDEX = 0; + constexpr int SIZE_INDEX = 1; + constexpr int COUNT_INDEX = 2; + + // Computes the end offset, exclusive + constexpr int dataViewEnd(const int info[3]) { + return info[OFFSET_INDEX] + info[SIZE_INDEX] * info[COUNT_INDEX]; + } + + constexpr int VERSION[3] = {0, UINT8_SIZE, 1}; + constexpr int ENABLED[3] = {dataViewEnd(VERSION), BOOL_SIZE, 1}; + constexpr int LOOK_AHEAD_CFG[3] = {dataViewEnd(ENABLED), FLOAT_SIZE, 4}; + constexpr int DISPLAY_RES[3] = {dataViewEnd(LOOK_AHEAD_CFG), UINT_SIZE, 2}; + constexpr int DISPLAY_FOV[3] = {dataViewEnd(DISPLAY_RES), FLOAT_SIZE, 1}; + constexpr int LENS_DISTANCE_RATIO[3] = {dataViewEnd(DISPLAY_FOV), FLOAT_SIZE, 1}; + constexpr int SBS_ENABLED[3] = {dataViewEnd(LENS_DISTANCE_RATIO), BOOL_SIZE, 1}; + constexpr int CUSTOM_BANNER_ENABLED[3] = {dataViewEnd(SBS_ENABLED), BOOL_SIZE, 1}; + constexpr int SMOOTH_FOLLOW_ENABLED[3] = {dataViewEnd(CUSTOM_BANNER_ENABLED), BOOL_SIZE, 1}; + constexpr int SMOOTH_FOLLOW_ORIGIN_DATA[3] = {dataViewEnd(SMOOTH_FOLLOW_ENABLED), FLOAT_SIZE, 16}; + constexpr int IMU_DATE_MS[3] = {dataViewEnd(SMOOTH_FOLLOW_ORIGIN_DATA), UINT_SIZE, 2}; + constexpr int IMU_QUAT_ENTRIES = 4; + constexpr int IMU_QUAT_DATA[3] = {dataViewEnd(IMU_DATE_MS), FLOAT_SIZE, 4 * IMU_QUAT_ENTRIES}; + constexpr int IMU_PARITY_BYTE[3] = {dataViewEnd(IMU_QUAT_DATA), UINT8_SIZE, 1}; + constexpr int LENGTH = dataViewEnd(IMU_PARITY_BYTE); +} + +namespace KWin +{ + +BreezyDesktopEffect::BreezyDesktopEffect() + : m_shutdownTimer(new QTimer(this)) +{ + qCCritical(KWIN_XR) << "\t\t\tBreezy - constructor"; + qmlRegisterUncreatableType("org.kde.kwin.effect.breezy_desktop", 1, 0, "BreezyDesktopEffect", QStringLiteral("BreezyDesktop cannot be created in QML")); + + m_shutdownTimer->setSingleShot(true); + connect(m_shutdownTimer, &QTimer::timeout, this, &BreezyDesktopEffect::realDeactivate); + + setupGlobalShortcut( + BreezyShortcuts::TOGGLE, + [this]() { this->toggle(); } + ); + setupGlobalShortcut( + BreezyShortcuts::RECENTER, + [this]() { this->recenter(); } + ); + setupGlobalShortcut( + BreezyShortcuts::TOGGLE_ZOOM_ON_FOCUS, + [this]() { + this->setZoomOnFocusEnabled(!m_zoomOnFocusEnabled); + } + ); + + connect(effects, &EffectsHandler::cursorShapeChanged, this, &BreezyDesktopEffect::updateCursorImage); + updateCursorImage(); + reconfigure(ReconfigureAll); + + setSource(QUrl::fromLocalFile(QStandardPaths::locate(QStandardPaths::GenericDataLocation, QStringLiteral("kwin/effects/breezy_desktop/qml/main.qml")))); + + // Monitor the IMU file for changes, even if it doesn't exist at startup + m_shmDirectoryWatcher = new QFileSystemWatcher(this); + m_shmDirectoryWatcher->addPath(DataView::SHM_DIR); + + m_shmFileWatcher = new QFileSystemWatcher(this); + + // Setup file watcher with recreation detection + auto setupFileWatcher = [this]() { + if (QFile::exists(DataView::SHM_PATH) && ( + m_imuTimestamp == 0 || + QDateTime::currentMSecsSinceEpoch() - m_imuTimestamp > 50 || // file may have been deleted and recreated + !m_shmFileWatcher->files().contains(DataView::SHM_PATH) + )) { + m_shmFileWatcher->removePath(DataView::SHM_PATH); + disconnect(m_shmFileWatcher, &QFileSystemWatcher::fileChanged, this, &BreezyDesktopEffect::updateImuRotation); + m_shmFileWatcher->addPath(DataView::SHM_PATH); + connect(m_shmFileWatcher, &QFileSystemWatcher::fileChanged, this, &BreezyDesktopEffect::updateImuRotation); + } + }; + + // Handle directory changes (file creation/recreation) + connect(m_shmDirectoryWatcher, &QFileSystemWatcher::directoryChanged, this, setupFileWatcher); + + // Initial setup + setupFileWatcher(); + + m_cursorUpdateTimer = new QTimer(this); + connect(m_cursorUpdateTimer, &QTimer::timeout, this, &BreezyDesktopEffect::updateCursorPos); + m_cursorUpdateTimer->setInterval(16); // ~60Hz + m_cursorUpdateTimer->start(); + + enableDriver(); +} + +void BreezyDesktopEffect::setupGlobalShortcut(const BreezyShortcuts::Shortcut &shortcut, std::function triggeredFunc) { + QAction *action = new QAction(this); + action->setObjectName(shortcut.actionName); + action->setText(shortcut.actionText); + KGlobalAccel::self()->setDefaultShortcut(action, {shortcut.shortcut}); + KGlobalAccel::self()->setShortcut(action, {shortcut.shortcut}); + connect(action, &QAction::triggered, this, triggeredFunc); +} + +void BreezyDesktopEffect::recenter() { + XRDriverIPC::instance().writeControlFlags({ + {"recenter_screen", true} + }); +} + +void BreezyDesktopEffect::reconfigure(ReconfigureFlags) +{ + BreezyDesktopConfig::self()->read(); + setFocusedDisplayDistance(BreezyDesktopConfig::focusedDisplayDistance() / 100.0f); + setAllDisplaysDistance(BreezyDesktopConfig::allDisplaysDistance() / 100.0f); + setDisplaySpacing(BreezyDesktopConfig::displaySpacing() / 1000.0f); + setZoomOnFocusEnabled(BreezyDesktopConfig::zoomOnFocusEnabled()); +} + +QVariantMap BreezyDesktopEffect::initialProperties(Output *screen) +{ + return QVariantMap{ + {QStringLiteral("effect"), QVariant::fromValue(this)}, + {QStringLiteral("targetScreen"), QVariant::fromValue(screen)} + }; +} + +int BreezyDesktopEffect::requestedEffectChainPosition() const +{ + return 70; +} + +void BreezyDesktopEffect::toggle() +{ + if (isRunning()) { + qCCritical(KWIN_XR) << "\t\t\tBreezy - toggle - deactivating"; + deactivate(); + } else { + qCCritical(KWIN_XR) << "\t\t\tBreezy - toggle - activating"; + activate(); + } +} + +void BreezyDesktopEffect::activate() +{ + qCCritical(KWIN_XR) << "\t\t\tBreezy - activate"; + + if (!isRunning()) setRunning(true); + + connect(effects, &EffectsHandler::cursorShapeChanged, this, &BreezyDesktopEffect::updateCursorImage); + m_cursorUpdateTimer->start(); + + // QuickSceneEffect grabs the keyboard and mouse input, which pulls focus away from the active window + // and doesn't allow for interaction with anything on the desktop. These two calls fix that. + effects->ungrabKeyboard(); + effects->stopMouseInterception(this); + + hideCursor(); + recenter(); +} + +void BreezyDesktopEffect::deactivate() +{ + if (m_shutdownTimer->isActive()) { + return; + } + + qCCritical(KWIN_XR) << "\t\t\tBreezy - deactivate"; + disconnect(effects, &EffectsHandler::cursorShapeChanged, this, &BreezyDesktopEffect::updateCursorImage); + m_cursorUpdateTimer->stop(); + showCursor(); + + for (auto output : m_virtualOutputs) { + KWin::kwinApp()->outputBackend()->removeVirtualOutput(output); + } + m_virtualOutputs.clear(); + + // this triggers realDeactivate with a delay so if it's triggered from QML it gives the QML function time to + // exit, avoiding a crash + m_shutdownTimer->start(250); +} + +void BreezyDesktopEffect::enableDriver() +{ + qCCritical(KWIN_XR) << "\t\t\tBreezy - enableDriver"; + XRDriverIPC::instance().writeConfig({ + {"disabled", false}, + {"output_mode", "external_only"}, + {"external_mode", "breezy_desktop"} + }); +} + +void BreezyDesktopEffect::realDeactivate() +{ + qCCritical(KWIN_XR) << "\t\t\tBreezy - realDeactivate"; + setRunning(false); +} + +void BreezyDesktopEffect::addVirtualDisplay(QSize size) +{ + // QSize size(2560, 1440); + // addVirtualDisplay(size); + + static int virtualDisplayCount = 0; + ++virtualDisplayCount; + QString name = QStringLiteral("BreezyDesktop_VirtualDisplay_%1x%2_%3").arg(size.width()).arg(size.height()).arg(virtualDisplayCount); + #if defined(KWIN_VERSION_ENCODED) && KWIN_VERSION_ENCODED >= 60290 + QString description = QStringLiteral("Breezy Display %1x%2 (%3)").arg(size.width()).arg(size.height()).arg(virtualDisplayCount); + auto output = KWin::kwinApp()->outputBackend()->createVirtualOutput(name, description, size, 1.0); + #else + auto output = KWin::kwinApp()->outputBackend()->createVirtualOutput(name, size, 1.0); + #endif + if (output) { + m_virtualOutputs.append(output); + } +} + +bool BreezyDesktopEffect::isEnabled() const { + return m_enabled; +} + +bool BreezyDesktopEffect::isZoomOnFocusEnabled() const { + return m_zoomOnFocusEnabled; +} + +void BreezyDesktopEffect::setZoomOnFocusEnabled(bool enabled) { + if (m_zoomOnFocusEnabled != enabled) { + m_zoomOnFocusEnabled = enabled; + BreezyDesktopConfig::setZoomOnFocusEnabled(enabled); + BreezyDesktopConfig::self()->save(); + Q_EMIT zoomOnFocusChanged(); + } +} + +bool BreezyDesktopEffect::imuResetState() const { + return m_imuResetState; +} + +QList BreezyDesktopEffect::imuRotations() const { + return m_imuRotations; +} + +quint32 BreezyDesktopEffect::imuTimeElapsedMs() const { + return m_imuTimeElapsedMs; +} + +quint64 BreezyDesktopEffect::imuTimestamp() const { + return m_imuTimestamp; +} + +QList BreezyDesktopEffect::lookAheadConfig() const { + return m_lookAheadConfig; +} + +QList BreezyDesktopEffect::displayResolution() const { + return m_displayResolution; +} + +qreal BreezyDesktopEffect::focusedDisplayDistance() const { + return m_focusedDisplayDistance; +} + +void BreezyDesktopEffect::setFocusedDisplayDistance(qreal distance) { + if (distance != m_focusedDisplayDistance) { + m_focusedDisplayDistance = std::clamp(distance, 0.2, m_allDisplaysDistance); + Q_EMIT displayDistanceChanged(); + } +} + +qreal BreezyDesktopEffect::allDisplaysDistance() const { + return m_allDisplaysDistance; +} + +void BreezyDesktopEffect::setAllDisplaysDistance(qreal distance) { + if (distance != m_allDisplaysDistance) { + m_allDisplaysDistance = std::clamp(distance, m_focusedDisplayDistance, 2.5); + Q_EMIT displayDistanceChanged(); + } +} + +qreal BreezyDesktopEffect::displaySpacing() const { + return m_displaySpacing; +} + +void BreezyDesktopEffect::setDisplaySpacing(qreal spacing) { + if (spacing != m_displaySpacing) { + m_displaySpacing = spacing; + Q_EMIT displaySpacingChanged(); + } +} + +qreal BreezyDesktopEffect::diagonalFOV() const { + return m_diagonalFOV; +} + +qreal BreezyDesktopEffect::lensDistanceRatio() const { + return m_lensDistanceRatio; +} + +bool BreezyDesktopEffect::sbsEnabled() const { + return m_sbsEnabled; +} + +bool BreezyDesktopEffect::customBannerEnabled() const { + return m_customBannerEnabled; +} + +bool BreezyDesktopEffect::checkParityByte(const char* data) { + const uint8_t parityByte = static_cast(data[DataView::IMU_PARITY_BYTE[DataView::OFFSET_INDEX]]); + uint8_t parity = 0; + + const int dateBytes = DataView::IMU_DATE_MS[DataView::COUNT_INDEX] * DataView::IMU_DATE_MS[DataView::SIZE_INDEX]; + for (int i = 0; i < dateBytes; ++i) { + parity ^= static_cast(data[DataView::IMU_DATE_MS[DataView::OFFSET_INDEX] + i]); + } + + const int quatBytes = DataView::IMU_QUAT_DATA[DataView::COUNT_INDEX] * DataView::IMU_QUAT_DATA[DataView::SIZE_INDEX]; + for (int i = 0; i < quatBytes; ++i) { + parity ^= static_cast(data[DataView::IMU_QUAT_DATA[DataView::OFFSET_INDEX] + i]); + } + + return parityByte == parity; +} + +// TODO - can this be something callable from the camera qml code, so it's pulled only when needed? +static qint64 lastConfigUpdate = 0; +static qint64 activatedAt = 0; +void BreezyDesktopEffect::updateImuRotation() { + const QString shmPath = QStringLiteral("/dev/shm/breezy_desktop_imu"); + QFile shmFile(shmPath); + if (!shmFile.open(QIODevice::ReadOnly)) { + return; + } + QByteArray buffer = shmFile.readAll(); + shmFile.close(); + if (buffer.size() != DataView::LENGTH) return; + + const char* data = buffer.constData(); + if (!checkParityByte(data)) return; + + uint8_t version = static_cast(data[DataView::VERSION[DataView::OFFSET_INDEX]]); + uint8_t enabledFlag = static_cast(data[DataView::ENABLED[DataView::OFFSET_INDEX]]); + uint64_t imuDateMs; + memcpy(&imuDateMs, data + DataView::IMU_DATE_MS[DataView::OFFSET_INDEX], sizeof(imuDateMs)); + imuDateMs = qFromLittleEndian(imuDateMs); + + const qint64 currentTimeMs = QDateTime::currentMSecsSinceEpoch(); + const bool updateConfig = lastConfigUpdate == 0 || currentTimeMs - lastConfigUpdate > 1000; + + if (updateConfig) { + float lookAheadConfig[4]; + memcpy(&lookAheadConfig[0], data + DataView::LOOK_AHEAD_CFG[DataView::OFFSET_INDEX], sizeof(lookAheadConfig)); + m_lookAheadConfig.clear(); + m_lookAheadConfig.append(lookAheadConfig[0]); + m_lookAheadConfig.append(lookAheadConfig[1]); + m_lookAheadConfig.append(lookAheadConfig[2]); + m_lookAheadConfig.append(lookAheadConfig[3]); + + uint32_t displayResolution[2]; + memcpy(&displayResolution[0], data + DataView::DISPLAY_RES[DataView::OFFSET_INDEX], sizeof(displayResolution)); + m_displayResolution.clear(); + m_displayResolution.append(displayResolution[0]); + m_displayResolution.append(displayResolution[1]); + + float displayFov = 0.0f; + memcpy(&displayFov, data + DataView::DISPLAY_FOV[DataView::OFFSET_INDEX], sizeof(displayFov)); + m_diagonalFOV = displayFov; + + float lensDistanceRatio = 0.0f; + memcpy(&lensDistanceRatio, data + DataView::LENS_DISTANCE_RATIO[DataView::OFFSET_INDEX], sizeof(lensDistanceRatio)); + m_lensDistanceRatio = lensDistanceRatio; + + uint8_t sbsEnabled = false; + memcpy(&sbsEnabled, data + DataView::SBS_ENABLED[DataView::OFFSET_INDEX], sizeof(sbsEnabled)); + m_sbsEnabled = (sbsEnabled != 0); + + uint8_t customBannerEnabled = false; + memcpy(&customBannerEnabled, data + DataView::CUSTOM_BANNER_ENABLED[DataView::OFFSET_INDEX], sizeof(customBannerEnabled)); + m_customBannerEnabled = (customBannerEnabled != 0); + + lastConfigUpdate = currentTimeMs; + } + + const bool validKeepAlive = (currentTimeMs - imuDateMs) < 5000; + const bool validData = validKeepAlive && m_diagonalFOV != 0.0f; + const uint8_t expectedVersion = 4; + bool enabledFlagSet = (enabledFlag != 0); + bool validVersion = (version == expectedVersion); + const bool wasEnabled = m_enabled; + const bool enabled = enabledFlagSet && validVersion && validData; + if (!enabled) { + // give a grace period after enabling the effect + if (wasEnabled && (currentTimeMs - activatedAt > 1000)) { + qCCritical(KWIN_XR) << "\t\t\tBreezy - disabling effect; currentTimeMs:" << currentTimeMs + << "imuDateMs:" << imuDateMs + << "enabledFlag:" << enabledFlag + << "version:" << version + << "diagonalFOV:" << m_diagonalFOV; + deactivate(); + m_enabled = false; + Q_EMIT enabledStateChanged(); + return; + } + } else if (!wasEnabled) { + qCCritical(KWIN_XR) << "\t\t\tBreezy - enabling effect; currentTimeMs:" << currentTimeMs + << "imuDateMs:" << imuDateMs + << "enabledFlag:" << enabledFlag + << "version:" << version + << "diagonalFOV:" << m_diagonalFOV; + activate(); + m_enabled = true; + Q_EMIT enabledStateChanged(); + activatedAt = currentTimeMs; + } + + if (updateConfig) Q_EMIT devicePropertiesChanged(); + + float imuData[4 * DataView::IMU_QUAT_ENTRIES]; // 4 quaternion-sized rows + memcpy(imuData, data + DataView::IMU_QUAT_DATA[DataView::OFFSET_INDEX], sizeof(imuData)); + m_imuResetState = (imuData[0] == 0.0f && imuData[1] == 0.0f && imuData[2] == 0.0f && imuData[3] == 1.0f); + + // convert NWU to EUS by passing root.rotation values: -y, z, -x + QQuaternion quatT0(imuData[3], -imuData[1], imuData[2], -imuData[0]); + + int imuDataOffset = DataView::IMU_QUAT_ENTRIES; + QQuaternion quatT1(imuData[imuDataOffset + 3], -imuData[imuDataOffset + 1], imuData[imuDataOffset + 2], -imuData[imuDataOffset + 0]); + + imuDataOffset += DataView::IMU_QUAT_ENTRIES; + + // skip the 3rd quaternion + imuDataOffset += DataView::IMU_QUAT_ENTRIES; + + // set imuRotations to the last two rotations, leave out the elapsed time + m_imuRotations.clear(); + m_imuRotations.append(quatT0); + m_imuRotations.append(quatT1); + + // 4th row isn't actually a quaternion, it contains the timestamps for each of the 3 quaternions + // elapsed time between T0 and T1 is: imuData[0] - imuData[1] + m_imuTimeElapsedMs = static_cast(imuData[imuDataOffset + 0] - imuData[imuDataOffset + 1]); + + m_imuTimestamp = imuDateMs; + Q_EMIT imuRotationsChanged(); +} + +QString BreezyDesktopEffect::cursorImageSource() const +{ + return m_cursorImageSource; +} + +QPointF BreezyDesktopEffect::cursorPos() const +{ + return m_cursorPos; +} + +void BreezyDesktopEffect::showCursor() +{ + effects->showCursor(); +} + +void BreezyDesktopEffect::hideCursor() +{ + updateCursorImage(); + effects->hideCursor(); +} + +void BreezyDesktopEffect::updateCursorImage() +{ + const auto cursor = effects->cursorImage(); + if (!cursor.image().isNull()) { + QByteArray data; + QBuffer buffer(&data); + buffer.open(QIODevice::WriteOnly); + cursor.image().save(&buffer, "PNG"); + + m_cursorImageSource = QStringLiteral("data:image/png;base64,%1").arg(QString::fromLatin1(data.toBase64())); + } else { + m_cursorImageSource = QString(); + } + Q_EMIT cursorImageChanged(); +} + +void BreezyDesktopEffect::updateCursorPos() +{ + // Update cursor position from effects + const auto cursor = effects->cursorImage(); + QPointF newPos = effects->cursorPos() - cursor.hotSpot(); + if (m_cursorPos != newPos) { + m_cursorPos = newPos; + Q_EMIT cursorPosChanged(); + } +} +} + +#include "moc_breezydesktopeffect.cpp" \ No newline at end of file diff --git a/kwin/src/breezydesktopeffect.h b/kwin/src/breezydesktopeffect.h new file mode 100644 index 0000000..ba3dee7 --- /dev/null +++ b/kwin/src/breezydesktopeffect.h @@ -0,0 +1,124 @@ +#pragma once + +#include "kcm/shortcuts.h" +#include + +#include +#include +#include +#include +#include + +namespace KWin +{ + class BreezyDesktopEffect : public QuickSceneEffect + { + Q_OBJECT + Q_PROPERTY(bool isEnabled READ isEnabled NOTIFY enabledStateChanged) + Q_PROPERTY(bool zoomOnFocusEnabled READ isZoomOnFocusEnabled WRITE setZoomOnFocusEnabled NOTIFY zoomOnFocusChanged) + Q_PROPERTY(bool imuResetState READ imuResetState NOTIFY imuRotationsChanged) + Q_PROPERTY(QList imuRotations READ imuRotations NOTIFY imuRotationsChanged) + Q_PROPERTY(quint32 imuTimeElapsedMs READ imuTimeElapsedMs NOTIFY imuRotationsChanged) + Q_PROPERTY(quint64 imuTimestamp READ imuTimestamp NOTIFY imuRotationsChanged) + Q_PROPERTY(QString cursorImageSource READ cursorImageSource NOTIFY cursorImageChanged) + Q_PROPERTY(QPointF cursorPos READ cursorPos NOTIFY cursorPosChanged) + Q_PROPERTY(QList lookAheadConfig READ lookAheadConfig NOTIFY devicePropertiesChanged) + Q_PROPERTY(QList displayResolution READ displayResolution NOTIFY devicePropertiesChanged) + Q_PROPERTY(qreal focusedDisplayDistance READ focusedDisplayDistance NOTIFY displayDistanceChanged) + Q_PROPERTY(qreal allDisplaysDistance READ allDisplaysDistance NOTIFY displayDistanceChanged) + Q_PROPERTY(qreal displaySpacing READ displaySpacing NOTIFY displaySpacingChanged) + Q_PROPERTY(qreal diagonalFOV READ diagonalFOV NOTIFY devicePropertiesChanged) + Q_PROPERTY(qreal lensDistanceRatio READ lensDistanceRatio NOTIFY devicePropertiesChanged) + Q_PROPERTY(bool sbsEnabled READ sbsEnabled NOTIFY devicePropertiesChanged) + Q_PROPERTY(bool customBannerEnabled READ customBannerEnabled NOTIFY devicePropertiesChanged) + + public: + + BreezyDesktopEffect(); + + void reconfigure(ReconfigureFlags) override; + + int requestedEffectChainPosition() const override; + + QString cursorImageSource() const; + QPointF cursorPos() const; + + bool isEnabled() const; + bool isZoomOnFocusEnabled() const; + void setZoomOnFocusEnabled(bool enabled); + QList imuRotations() const; + quint32 imuTimeElapsedMs() const; + quint64 imuTimestamp() const; + bool imuResetState() const; + QList lookAheadConfig() const; + QList displayResolution() const; + qreal focusedDisplayDistance() const; + void setFocusedDisplayDistance(qreal distance); + qreal allDisplaysDistance() const; + void setAllDisplaysDistance(qreal distance); + qreal displaySpacing() const; + void setDisplaySpacing(qreal spacing); + qreal diagonalFOV() const; + qreal lensDistanceRatio() const; + bool sbsEnabled() const; + bool customBannerEnabled() const; + + void showCursor(); + void hideCursor(); + + public Q_SLOTS: + void activate(); + void deactivate(); + void enableDriver(); + void toggle(); + void addVirtualDisplay(QSize size); + void updateImuRotation(); + void updateCursorImage(); + void updateCursorPos(); + + Q_SIGNALS: + void displayDistanceChanged(); + void displaySpacingChanged(); + void enabledStateChanged(); + void zoomOnFocusChanged(); + void imuRotationsChanged(); + void cursorImageChanged(); + void cursorPosChanged(); + void devicePropertiesChanged(); + + protected: + QVariantMap initialProperties(Output *screen) override; + + private: + void realDeactivate(); + bool checkParityByte(const char* data); + void setupGlobalShortcut(const BreezyShortcuts::Shortcut &shortcut, + std::function triggeredFunc); + void recenter(); + + QTimer *m_shutdownTimer; + QString m_cursorImageSource; + + bool m_enabled = false; + bool m_zoomOnFocusEnabled = false; + bool m_imuResetState; + QList m_imuRotations; + quint32 m_imuTimeElapsedMs; + quint64 m_imuTimestamp = 0; + QList m_lookAheadConfig; + QList m_displayResolution; + qreal m_diagonalFOV; + qreal m_lensDistanceRatio; + bool m_sbsEnabled; + bool m_customBannerEnabled; + QFileSystemWatcher *m_shmFileWatcher = nullptr; + QFileSystemWatcher *m_shmDirectoryWatcher = nullptr; + QPointF m_cursorPos; + QTimer *m_cursorUpdateTimer = nullptr; + qreal m_focusedDisplayDistance = 0.85; + qreal m_allDisplaysDistance = 1.05; + qreal m_displaySpacing = 0.0; + QList m_virtualOutputs; + }; + +} // namespace KWin diff --git a/kwin/src/kcm/CMakeLists.txt b/kwin/src/kcm/CMakeLists.txt new file mode 100644 index 0000000..34e048e --- /dev/null +++ b/kwin/src/kcm/CMakeLists.txt @@ -0,0 +1,18 @@ +set(breezy_desktop_config_SOURCES breezydesktopeffectkcm.cpp labeledslider.cpp) +ki18n_wrap_ui(breezy_desktop_config_SOURCES breezydesktopeffectkcm.ui) +qt_add_dbus_interface(breezy_desktop_config_SOURCES ${KWIN_EFFECTS_INTERFACE} kwineffects_interface) + +kcoreaddons_add_plugin(breezy_desktop_config INSTALL_NAMESPACE "kwin/effects/configs" SOURCES ${breezy_desktop_config_SOURCES}) +kconfig_add_kcfg_files(breezy_desktop_config ../breezydesktopconfig.kcfgc) +target_link_libraries(breezy_desktop_config + KF6::ConfigCore + KF6::ConfigGui + KF6::ConfigWidgets + KF6::CoreAddons + KF6::GlobalAccel + KF6::I18n + KF6::KCMUtils + KF6::XmlGui + + xr_driver_ipc +) diff --git a/kwin/src/kcm/breezydesktopeffectkcm.cpp b/kwin/src/kcm/breezydesktopeffectkcm.cpp new file mode 100644 index 0000000..fc0c10d --- /dev/null +++ b/kwin/src/kcm/breezydesktopeffectkcm.cpp @@ -0,0 +1,151 @@ +#include "shortcuts.h" +#include "breezydesktopeffectkcm.h" +#include "breezydesktopconfig.h" +#include "labeledslider.h" +#include "xrdriveripc.h" + +#include + +#include +#include +#include +#include +#include +#include + +#include + +#include + +Q_LOGGING_CATEGORY(KWIN_XR, "kwin.xr") + +static const char EFFECT_GROUP[] = "Effect-breezy_desktop"; + +void addShortcutAction(KActionCollection *collection, const BreezyShortcuts::Shortcut &shortcut) +{ + QAction *action = collection->addAction(shortcut.actionName); + action->setText(shortcut.actionText); + action->setProperty("isConfigurationAction", true); + KGlobalAccel::self()->setDefaultShortcut(action, {shortcut.shortcut}); + KGlobalAccel::self()->setShortcut(action, {shortcut.shortcut}); +} + +K_PLUGIN_CLASS(BreezyDesktopEffectConfig) + +BreezyDesktopEffectConfig::BreezyDesktopEffectConfig(QObject *parent, const KPluginMetaData &data) + : KCModule(parent, data) +{ + ui.setupUi(widget()); + addConfig(BreezyDesktopConfig::self(), widget()); + + m_statePollTimer.setInterval(2000); + m_statePollTimer.setTimerType(Qt::CoarseTimer); + connect(&m_statePollTimer, &QTimer::timeout, this, &BreezyDesktopEffectConfig::pollDriverState); + m_statePollTimer.start(); + + m_configWatcher = KConfigWatcher::create(BreezyDesktopConfig::self()->sharedConfig()); + if (m_configWatcher) { + connect(m_configWatcher.data(), &KConfigWatcher::configChanged, this, + [this](const KConfigGroup &group) { + if (m_updatingFromConfig) { + return; + } + if (group.name() != QLatin1String(EFFECT_GROUP)) { + return; + } + BreezyDesktopConfig::self()->read(); + updateUiFromConfig(); + updateUnmanagedState(); + }); + } + + auto actionCollection = new KActionCollection(this, QStringLiteral("kwin")); + actionCollection->setComponentDisplayName(i18n("KWin")); + actionCollection->setConfigGroup(QStringLiteral("breezy_desktop")); + actionCollection->setConfigGlobal(true); + + addShortcutAction(actionCollection, BreezyShortcuts::TOGGLE); + addShortcutAction(actionCollection, BreezyShortcuts::RECENTER); + addShortcutAction(actionCollection, BreezyShortcuts::TOGGLE_ZOOM_ON_FOCUS); + ui.shortcutsEditor->addCollection(actionCollection); + connect(ui.shortcutsEditor, &KShortcutsEditor::keyChange, this, &BreezyDesktopEffectConfig::markAsChanged); + connect(ui.kcfg_FocusedDisplayDistance, &QSlider::valueChanged, this, &BreezyDesktopEffectConfig::save); + connect(ui.kcfg_AllDisplaysDistance, &QSlider::valueChanged, this, &BreezyDesktopEffectConfig::save); + connect(ui.kcfg_DisplaySpacing, &QSlider::valueChanged, this, &BreezyDesktopEffectConfig::save); +} + +BreezyDesktopEffectConfig::~BreezyDesktopEffectConfig() +{ +} + +void BreezyDesktopEffectConfig::load() +{ + KCModule::load(); + updateUiFromConfig(); + updateUnmanagedState(); +} + +void BreezyDesktopEffectConfig::save() +{ + // Prevent reacting to the file change we ourselves are about to write. + m_updatingFromConfig = true; + updateConfigFromUi(); + BreezyDesktopConfig::self()->save(); + KCModule::save(); + ui.kcfg_FocusedDisplayDistance->setEnabled(ui.kcfg_ZoomOnFocusEnabled->isChecked()); + m_updatingFromConfig = false; + updateUnmanagedState(); + + OrgKdeKwinEffectsInterface interface(QStringLiteral("org.kde.KWin"), QStringLiteral("/Effects"), QDBusConnection::sessionBus()); + interface.reconfigureEffect(QStringLiteral("breezy_desktop")); +} + +void BreezyDesktopEffectConfig::defaults() +{ + KCModule::defaults(); + updateUiFromDefaultConfig(); + updateUnmanagedState(); +} + +void BreezyDesktopEffectConfig::updateConfigFromUi() +{ + ui.shortcutsEditor->save(); +} + +void BreezyDesktopEffectConfig::updateUiFromConfig() +{ + ui.kcfg_FocusedDisplayDistance->setValue(BreezyDesktopConfig::self()->focusedDisplayDistance()); + ui.kcfg_AllDisplaysDistance->setValue(BreezyDesktopConfig::self()->allDisplaysDistance()); + ui.kcfg_DisplaySpacing->setValue(BreezyDesktopConfig::self()->displaySpacing()); + ui.kcfg_ZoomOnFocusEnabled->setChecked(BreezyDesktopConfig::self()->zoomOnFocusEnabled()); + ui.kcfg_FocusedDisplayDistance->setEnabled(ui.kcfg_ZoomOnFocusEnabled->isChecked()); +} + +void BreezyDesktopEffectConfig::updateUiFromDefaultConfig() +{ + ui.shortcutsEditor->allDefault(); +} + +void BreezyDesktopEffectConfig::updateUnmanagedState() +{ +} + +void BreezyDesktopEffectConfig::pollDriverState() +{ + auto &bridge = XRDriverIPC::instance(); + auto stateOpt = bridge.retrieveDriverState(); + const XRDict &state = stateOpt.value(); + + m_connectedDeviceBrand = QString::fromStdString(XRDriverIPC::string(state, XRStateEntry::ConnectedDeviceBrand)); + m_connectedDeviceModel = QString::fromStdString(XRDriverIPC::string(state, XRStateEntry::ConnectedDeviceModel)); + + const bool wasDeviceConnected = m_deviceConnected; + m_deviceConnected = !m_connectedDeviceBrand.isEmpty() && !m_connectedDeviceModel.isEmpty(); + if (m_deviceConnected != wasDeviceConnected) { + ui.labelDeviceConnectionStatus->setText(m_deviceConnected ? + QStringLiteral("%1 %2 connected").arg(m_connectedDeviceBrand, m_connectedDeviceModel) : + QStringLiteral("No device connected")); + } +} + +#include "breezydesktopeffectkcm.moc" \ No newline at end of file diff --git a/kwin/src/kcm/breezydesktopeffectkcm.h b/kwin/src/kcm/breezydesktopeffectkcm.h new file mode 100644 index 0000000..13d354c --- /dev/null +++ b/kwin/src/kcm/breezydesktopeffectkcm.h @@ -0,0 +1,42 @@ +#pragma once + +#include +#include +#include + +#include + +#include "ui_breezydesktopeffectkcm.h" + +class KConfigWatcher; +class KConfigGroup; + +class BreezyDesktopEffectConfig : public KCModule +{ + Q_OBJECT + +public: + BreezyDesktopEffectConfig(QObject *parent, const KPluginMetaData &data); + ~BreezyDesktopEffectConfig() override; + +public Q_SLOTS: + void load() override; + void save() override; + void defaults() override; + +private: + void updateUiFromConfig(); + void updateUiFromDefaultConfig(); + void updateConfigFromUi(); + void updateUnmanagedState(); + void pollDriverState(); + + ::Ui::BreezyDesktopEffectConfig ui; + + KConfigWatcher::Ptr m_configWatcher; + bool m_updatingFromConfig = false; + bool m_deviceConnected = false; + QString m_connectedDeviceBrand; + QString m_connectedDeviceModel; + QTimer m_statePollTimer; // periodic driver state polling +}; diff --git a/kwin/src/kcm/breezydesktopeffectkcm.ui b/kwin/src/kcm/breezydesktopeffectkcm.ui new file mode 100644 index 0000000..c431a53 --- /dev/null +++ b/kwin/src/kcm/breezydesktopeffectkcm.ui @@ -0,0 +1,159 @@ + + + BreezyDesktopEffectConfig + + + + + + No device connected + + + Qt::AlignHCenter|Qt::AlignVCenter + + + + 14 + 75 + true + + + + + + + + QTabWidget::North + + + QTabWidget::Rounded + + + + &General + + + + + + Zoom on Focus + + + false + + + + + + + Focused Display Distance: + + + + + + + 2 + + + QSlider::TicksBelow + + + 25 + + + Qt::Horizontal + + + true + + + + + + + All Displays Distance: + + + + + + + 2 + + + QSlider::TicksBelow + + + 25 + + + Qt::Horizontal + + + true + + + + + + + Display Spacing: + + + + + + + Qt::Horizontal + + + true + + + + + + + + 0 + 0 + + + + + + + + + &Advanced + + + + + + Advanced settings will appear here. + + + Qt::AlignCenter + + + + + + + + + + + + KShortcutsEditor + QWidget +
kshortcutseditor.h
+ 1 +
+
+ + + +
diff --git a/kwin/src/kcm/labeledslider.cpp b/kwin/src/kcm/labeledslider.cpp new file mode 100644 index 0000000..23cdc32 --- /dev/null +++ b/kwin/src/kcm/labeledslider.cpp @@ -0,0 +1 @@ +#include "labeledslider.h" \ No newline at end of file diff --git a/kwin/src/kcm/labeledslider.h b/kwin/src/kcm/labeledslider.h new file mode 100644 index 0000000..6a8a4ec --- /dev/null +++ b/kwin/src/kcm/labeledslider.h @@ -0,0 +1,157 @@ +#pragma once + +#include +#include +#include +#include +#include // for std::max + +/* + * LabeledSlider + * Horizontal QSlider that draws numeric labels at tick intervals and (optionally) a value bubble. + * Usage: + * auto *s = new LabeledSlider(parent); + * s->setMinimum(20); + * s->setMaximum(250); + * s->setTickInterval(20); + * s->setTickPosition(QSlider::TicksBelow); + * s->setShowValueBubble(true); + */ + +class LabeledSlider : public QSlider { + Q_OBJECT + Q_PROPERTY(bool showValueBubble READ showValueBubble WRITE setShowValueBubble) + // decimalShift: number of places to shift the decimal point left for display ONLY. + // Example: raw value 250 with decimalShift=2 displays as 2.50. Underlying slider value + // (signals, stored config) remains 250. + Q_PROPERTY(int decimalShift READ decimalShift WRITE setDecimalShift) +public: + explicit LabeledSlider(QWidget *parent = nullptr) + : QSlider(Qt::Horizontal, parent) + { + setTickPosition(QSlider::TicksBelow); + } + + bool showValueBubble() const { return m_showValueBubble; } + void setShowValueBubble(bool on) { + if (m_showValueBubble == on) return; + m_showValueBubble = on; + update(); + } + + int decimalShift() const { return m_decimalShift; } + void setDecimalShift(int shift) { + // clamp to sensible range + if (shift < 0) shift = 0; + if (shift > 6) shift = 6; // avoid large power-of-10 overflow + if (m_decimalShift == shift) return; + m_decimalShift = shift; + updateGeometry(); + update(); + } + + QSize sizeHint() const override { + QSize sz = QSlider::sizeHint(); + int extraH = 0; + if (labelInterval() > 0) { + // Reserve space for bottom labels + QFontMetrics fm(font()); + extraH += fm.height() + 4; + } + if (m_showValueBubble) { + QFontMetrics fm(font()); + extraH = std::max(extraH, fm.height() + 8); // bubble might be above + } + sz.setHeight(sz.height() + extraH); + return sz; + } + +protected: + void paintEvent(QPaintEvent *e) override { + QSlider::paintEvent(e); + + QStyleOptionSlider opt; + initStyleOption(&opt); + + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing, true); + + const int minV = minimum(); + const int maxV = maximum(); + + // Draw labels below ticks + if (labelInterval() > 0) { + QFontMetrics fm(font()); + const int baselineY = height() - fm.descent() - 1; + int interval = labelInterval(); + for (int v = minV; v <= maxV; v += interval) { + // Use style geometry for handle at this position to match tick placement. + QStyleOptionSlider optPos = opt; + optPos.sliderPosition = v; + optPos.sliderValue = v; + QRect handleAtVal = style()->subControlRect(QStyle::CC_Slider, &optPos, QStyle::SC_SliderHandle, this); + int x = handleAtVal.center().x(); + QString text = valueToDisplayString(v); + int halfW = fm.horizontalAdvance(text) / 2; + QRect r(x - halfW, baselineY - fm.ascent(), fm.horizontalAdvance(text), fm.height()); + p.drawText(r, Qt::AlignCenter, text); + } + } + + // Draw floating value bubble over handle + if (m_showValueBubble) { + // Handle rect + const QRect handle = style()->subControlRect(QStyle::CC_Slider, &opt, QStyle::SC_SliderHandle, this); + QString valText = valueToDisplayString(value()); + QFontMetrics fm(font()); + QRect textRect = fm.boundingRect(valText); + textRect.adjust(-6, -4, 6, 4); + + // Position bubble above the handle; add extra lift + const int extraLift = 10; // requested additional pixels + const int gap = 4; // minimal gap between handle top and bubble + int topY = handle.top() - gap - extraLift - textRect.height(); + if (topY < 0) topY = 0; // clamp to widget + textRect.moveTop(topY); + textRect.moveLeft(handle.center().x() - textRect.width()/2); + + // Bubble shape + QPainterPath path; + path.addRoundedRect(textRect, 6, 6); + + p.setPen(Qt::NoPen); + p.setBrush(palette().toolTipBase()); + p.drawPath(path); + + p.setPen(palette().toolTipText().color()); + p.drawText(textRect, Qt::AlignCenter, valText); + } + } + +private: + QString valueToDisplayString(int raw) const { + if (m_decimalShift == 0) { + return QString::number(raw); + } + int divisor = 1; + for (int i = 0; i < m_decimalShift; ++i) divisor *= 10; // small loop, m_decimalShift capped + int whole = raw / divisor; + int frac = std::abs(raw % divisor); + QString fracStr = QString::number(frac).rightJustified(m_decimalShift, QLatin1Char('0')); + return QString::number(whole) + QLatin1Char('.') + fracStr; + } + + bool m_showValueBubble = true; + int m_decimalShift = 0; // display-only decimal shift +private: + int labelInterval() const { + int ti = tickInterval(); + if (ti > 0) return ti; + // Heuristic fallback: divide range into ~10 segments. + int range = maximum() - minimum(); + if (range <= 0) return 0; + int approx = range / 10; + if (approx <= 0) approx = range; // single label at ends + return approx; + } +}; \ No newline at end of file diff --git a/kwin/src/kcm/shortcuts.h b/kwin/src/kcm/shortcuts.h new file mode 100644 index 0000000..f5792a6 --- /dev/null +++ b/kwin/src/kcm/shortcuts.h @@ -0,0 +1,31 @@ +#pragma once + +#include +#include +#include + +namespace BreezyShortcuts { + struct Shortcut { + QKeySequence shortcut; + QString actionName; + QString actionText; + }; + + const Shortcut TOGGLE = { + Qt::CTRL | Qt::META | Qt::Key_Backslash, + QStringLiteral("Toggle XR Effect"), + QStringLiteral("Toggle XR Effect") + }; + + const Shortcut RECENTER = { + Qt::CTRL | Qt::META | Qt::Key_Space, + QStringLiteral("Recenter"), + QStringLiteral("Recenter") + }; + + const Shortcut TOGGLE_ZOOM_ON_FOCUS = { + Qt::CTRL | Qt::META | Qt::Key_0, + QStringLiteral("Toggle Zoom on Focus"), + QStringLiteral("Toggle Zoom on Focus") + }; +} diff --git a/kwin/src/main.cpp b/kwin/src/main.cpp new file mode 100644 index 0000000..fd243a0 --- /dev/null +++ b/kwin/src/main.cpp @@ -0,0 +1,10 @@ +#include "breezydesktopeffect.h" + +namespace KWin +{ + +KWIN_EFFECT_FACTORY(BreezyDesktopEffect, "metadata.json") + +} // namespace KWin + +#include "main.moc" diff --git a/kwin/src/metadata.json b/kwin/src/metadata.json new file mode 100644 index 0000000..8d582c4 --- /dev/null +++ b/kwin/src/metadata.json @@ -0,0 +1,17 @@ +{ + "KPackageStructure": "KWin/Effect", + "KPlugin": { + "Authors": [ + { + "Email": "wayne@xronlinux.com", + "Name": "Wayne Heaney" + } + ], + "Category": "Tools", + "Description": "Breezy Desktop XR Effect", + "EnabledByDefault": true, + "License": "GPL", + "Name": "Breezy Desktop XR" + }, + "X-KDE-ConfigModule": "breezy_desktop_config" +} diff --git a/kwin/src/qml/BreezyDesktop.qml b/kwin/src/qml/BreezyDesktop.qml new file mode 100644 index 0000000..8ffa052 --- /dev/null +++ b/kwin/src/qml/BreezyDesktop.qml @@ -0,0 +1,147 @@ +import QtQuick +import QtQuick3D + + +Node { + id: breezyDesktop + + property var viewportResolution: effect.displayResolution + property var screens: root.screens + property var fovDetails: root.fovDetails + property var monitorPlacements: root.monitorPlacements + property var imuRotations: effect.imuRotations + property int focusedMonitorIndex: -1 + + Displays { + id: displays + } + + function displayAtIndex(index) { + if (index < 0 || index >= screens.length) { + return null; + } + return breezyDesktopDisplays.objectAt(index); + } + + Repeater3D { + id: breezyDesktopDisplays + model: screens.length + delegate: BreezyDesktopDisplay { + screen: screens[index] + monitorPlacement: monitorPlacements[index] + property real monitorDistance: effect.allDisplaysDistance + property real targetDistance: effect.allDisplaysDistance + property real screenRotationY: displays.radianToDegree(monitorPlacement.rotationAngleRadians.y) + property real screenRotationX: displays.radianToDegree(monitorPlacement.rotationAngleRadians.x) + + property vector3d screenScale: { + const geometry = screen.geometry; + + // apparently the default model unit size is 100x100, so we scale it up to the screen size + return Qt.vector3d(geometry.width / 100, geometry.height / 100, 1); + } + + scale: screenScale + eulerRotation.y: screenRotationY + eulerRotation.x: screenRotationX + position: { + // camera looks along the negative Z axis + const positionVector = + displays.nwuToEusVector(monitorPlacement.centerNoRotate) + .times(monitorDistance / effect.allDisplaysDistance); + + // position vector is only translated in flat directions, without rotations applied, so apply them here + const rotationMatrix = Qt.matrix4x4(); + + // only one of these should ever be non-zero, since we only rotate in the direction of the "wrap" preference + rotationMatrix.rotate(screenRotationY, Qt.vector3d(0, 1, 0)); + rotationMatrix.rotate(screenRotationX, Qt.vector3d(1, 0, 0)); + + return rotationMatrix.times(positionVector); + } + } + } + + Timer { + interval: 500 // 500ms - 2x per second to avoid running this check too frequently + repeat: true + running: true + onTriggered: { + if (breezyDesktop.imuRotations && breezyDesktop.imuRotations.length > 0) { + let focusedIndex = -1; + + if (effect.zoomOnFocusEnabled) { + focusedIndex = displays.findFocusedMonitor( + displays.eusToNwuQuat(breezyDesktop.imuRotations[0]), + breezyDesktop.monitorPlacements.map(monitorVectors => monitorVectors.centerLook), + breezyDesktop.focusedMonitorIndex, + false, // TODO smooth follow + breezyDesktop.fovDetails, + breezyDesktop.screens.map(screen => screen.geometry) + ); + } + + const focusedDisplay = focusedIndex !== -1 ? breezyDesktop.displayAtIndex(focusedIndex) : null; + if (focusedIndex !== breezyDesktop.focusedMonitorIndex) { + if (focusedDisplay === null) { + zoomOutAnimation.target = breezyDesktop.displayAtIndex(breezyDesktop.focusedMonitorIndex); + zoomOutAnimation.target.targetDistance = zoomOutAnimation.to; + zoomOutAnimation.start(); + } else { + if (breezyDesktop.focusedMonitorIndex === -1) { + zoomInAnimation.target = focusedDisplay; + focusedDisplay.targetDistance = zoomInAnimation.to; + zoomInAnimation.start(); + } else { + zoomInSeqAnimation.target = focusedDisplay; + focusedDisplay.targetDistance = zoomInSeqAnimation.to; + zoomOutSeqAnimation.target = breezyDesktop.displayAtIndex(breezyDesktop.focusedMonitorIndex); + zoomOutSeqAnimation.target.targetDistance = zoomOutSeqAnimation.to; + zoomOnFocusSequence.start(); + } + } + breezyDesktop.focusedMonitorIndex = focusedIndex; + } else if (focusedDisplay !== null && focusedDisplay.targetDistance !== effect.focusedDisplayDistance) { + // user is changing the focused display distance setting, so just move it to match + focusedDisplay.monitorDistance = effect.focusedDisplayDistance; + focusedDisplay.targetDistance = effect.focusedDisplayDistance; + } + } + } + } + + NumberAnimation { + id: zoomOutAnimation + property: "monitorDistance" + to: effect.allDisplaysDistance + duration: 150 + running: false + } + + NumberAnimation { + id: zoomInAnimation + property: "monitorDistance" + to: effect.focusedDisplayDistance + duration: 300 + running: false + } + + SequentialAnimation { + id: zoomOnFocusSequence + running: false + + NumberAnimation { + id: zoomOutSeqAnimation + property: "monitorDistance" + to: effect.allDisplaysDistance + duration: 150 + } + PauseAnimation { duration: 50 } + NumberAnimation { + id: zoomInSeqAnimation + property: "monitorDistance" + to: effect.focusedDisplayDistance + duration: 300 + } + } +} diff --git a/kwin/src/qml/BreezyDesktopDisplay.qml b/kwin/src/qml/BreezyDesktopDisplay.qml new file mode 100644 index 0000000..d7ea78a --- /dev/null +++ b/kwin/src/qml/BreezyDesktopDisplay.qml @@ -0,0 +1,26 @@ +import QtQuick +import QtQuick3D + +Model { + id: display + + required property QtObject screen + required property var monitorPlacement + required property int index + + source: "#Rectangle" + materials: [ + DefaultMaterial { + cullMode: Material.NoCulling + lighting: DefaultMaterial.NoLighting + depthDrawMode: Material.AlwaysDepthDraw + diffuseMap: Texture { + sourceItem: DesktopView { + screen: display.screen + width: display.screen.geometry.width + height: display.screen.geometry.height + } + } + } + ] +} diff --git a/kwin/src/qml/CameraController.qml b/kwin/src/qml/CameraController.qml new file mode 100644 index 0000000..15b5276 --- /dev/null +++ b/kwin/src/qml/CameraController.qml @@ -0,0 +1,84 @@ +import QtQuick +import QtQuick3D + +Item { + id: root + + required property Camera camera + + property var imuRotations: effect.imuRotations + property int imuTimeElapsedMs: effect.imuTimeElapsedMs + property double imuTimestamp: effect.imuTimestamp + property var lookAheadConfig: effect.lookAheadConfig + property var displayResolution: effect.displayResolution + property real diagonalFOV: effect.diagonalFOV + property real lensDistanceRatio: effect.lensDistanceRatio + property bool sbsEnabled: effect.sbsEnabled + property bool customBannerEnabled: effect.customBannerEnabled + + implicitWidth: parent.width + implicitHeight: parent.height + + Displays { + id: displays + } + + function updateCamera(rotation) { + camera.eulerRotation = rotation; + } + + // how far to look ahead is how old the IMU data is plus a constant that is either the default for this device or an override + function lookAheadMS(imuDateMs, lookAheadConfig, override) { + // how stale the imu data is + const dataAge = Date.now() - imuDateMs; + + const lookAheadConstant = lookAheadConfig[0]; + const lookAheadMultiplier = lookAheadConfig[1]; + return (override === -1 ? lookAheadConstant : override) + dataAge; + } + + function applyLookAhead(quatT0, quatT1, elapsedTimeMs, lookAheadMs) { + // convert both quats to euler angles + const eulerT0 = quatT0.toEulerAngles(); + const eulerT1 = quatT1.toEulerAngles(); + + // compute the rate of change of the angles based on the elapsed time + const deltaX = (eulerT0.x - eulerT1.x); + const deltaY = (eulerT0.y - eulerT1.y); + const deltaZ = (eulerT0.z - eulerT1.z); + + // how much of the delta to apply based on the look-ahead time + const timeConstant = lookAheadMs / elapsedTimeMs; + + return Qt.vector3d( + eulerT0.x + deltaX * timeConstant, + eulerT0.y + deltaY * timeConstant, + eulerT0.z + deltaZ * timeConstant, + ); + } + + function updateFOV() { + const aspectRatio = displayResolution[0] / displayResolution[1]; + camera.fieldOfView = displays.radianToDegree(displays.diagonalToCrossFOVs( + displays.degreeToRadian(root.diagonalFOV), + aspectRatio + ).vertical); + } + + onDisplayResolutionChanged: updateFOV(); + onDiagonalFOVChanged: updateFOV(); + + FrameAnimation { + running: true + onTriggered: { + if (root.imuRotations && root.imuRotations.length > 0) { + updateCamera(applyLookAhead( + root.imuRotations[0], + root.imuRotations[1], + root.imuTimeElapsedMs, + lookAheadMS(root.imuTimestamp, root.lookAheadConfig, -1) + )); + } + } + } +} diff --git a/kwin/src/qml/DesktopView.qml b/kwin/src/qml/DesktopView.qml new file mode 100644 index 0000000..03cacd6 --- /dev/null +++ b/kwin/src/qml/DesktopView.qml @@ -0,0 +1,57 @@ +import QtQuick +import org.kde.kwin as KWinComponents + +Item { + id: desktopView + + required property QtObject screen + + function overlapsScreen(win, screenGeom) { + if (!win) return false + const winLeft = win.x + const winTop = win.y + const winRight = winLeft + win.width + const winBottom = winTop + win.height + + const scrLeft = screenGeom.x + const scrTop = screenGeom.y + const scrRight = scrLeft + screenGeom.width + const scrBottom = scrTop + screenGeom.height + + return winLeft < scrRight && + winRight > scrLeft && + winTop < scrBottom && + winBottom > scrTop + } + + Repeater { + model: KWinComponents.WindowModel {} + + KWinComponents.WindowThumbnail { + // Only show if window overlaps this screen (any amount) and not minimized. + readonly property bool onThisActivity: model.window.activities.length === 0 || model.window.activities.includes(KWinComponents.Workspace.currentActivity) + readonly property bool onThisDesktop: onThisActivity && (model.window.onAllDesktops || model.window.desktops.includes(KWinComponents.Workspace.currentDesktop)) + readonly property bool onThisScreen: onThisDesktop && desktopView.overlapsScreen(model.window, desktopView.screen.geometry) + + wId: model.window.internalId + x: model.window.x - desktopView.screen.geometry.x + y: model.window.y - desktopView.screen.geometry.y + z: model.window.stackingOrder + visible: onThisScreen && !model.window.minimized + } + } + + Image { + id: cursorImg + source: effect.cursorImageSource + cache: false + visible: true // TODO - cursor position bounds check? + x: effect.cursorPos.x - desktopView.screen.geometry.x + y: effect.cursorPos.y - desktopView.screen.geometry.y + z: 9999 // ensure on top + anchors.centerIn: undefined + + layer.enabled: true + layer.smooth: true + } +} diff --git a/kwin/src/qml/Displays.qml b/kwin/src/qml/Displays.qml new file mode 100644 index 0000000..69e79af --- /dev/null +++ b/kwin/src/qml/Displays.qml @@ -0,0 +1,400 @@ +import QtQuick + +QtObject { + readonly property real focusThreshold: 0.95 / 2.0 + readonly property real unfocusThreshold: 1.1 / 2.0 + + // Converts degrees to radians + function degreeToRadian(degree) { + return degree * Math.PI / 180; + } + + function radianToDegree(radian) { + return radian * 180 / Math.PI; + } + + function nwuToEusVector(vector) { + // Converts NWU vector to EUS vector + return Qt.vector3d(-vector.y, vector.z, -vector.x); + } + + function eusToNwuQuat(quaternion) { + // Converts EUS quaternion to NWU quaternion + return Qt.quaternion(quaternion.scalar, -quaternion.z, -quaternion.x, quaternion.y); + } + + // Converts diagonal FOV in radians and aspect ratio to horizontal and vertical FOVs + function diagonalToCrossFOVs(diagonalFOVRadians, aspectRatio) { + var flatDiagonalFOV = 2 * Math.tan(diagonalFOVRadians / 2); + var flatVerticalFOV = flatDiagonalFOV / Math.sqrt(1 + aspectRatio * aspectRatio); + var flatHorizontalFOV = flatVerticalFOV * aspectRatio; + return { + diagonal: diagonalFOVRadians, + horizontal: 2 * Math.atan(flatHorizontalFOV / 2), + vertical: 2 * Math.atan(flatVerticalFOV / 2) + } + } + + function actualWrapScheme(screens, viewportWidth, viewportHeight) { + const minX = Math.min(...screens.map(screen => screen.geometry.x)); + const maxX = Math.max(...screens.map(screen => screen.geometry.x + screen.geometry.width)); + const minY = Math.min(...screens.map(screen => screen.geometry.y)); + const maxY = Math.max(...screens.map(screen => screen.geometry.y + screen.geometry.height)); + + if ((maxX - minX) / viewportWidth >= (maxY - minY) / viewportHeight) { + return 'horizontal'; + } else { + return 'vertical'; + } + } + + function fovDetails(screens, viewportWidth, viewportHeight, viewportDiagonalFOV, lensDistanceRatio) { + const aspect = viewportWidth / viewportHeight; + const fovRadians = diagonalToCrossFOVs(degreeToRadian(viewportDiagonalFOV), aspect); + const defaultDistanceVerticalRadians = 2 * Math.atan(Math.tan(fovRadians.vertical / 2) / effect.allDisplaysDistance); + const defaultDistanceHorizontalRadians = 2 * Math.atan(Math.tan(fovRadians.horizontal / 2) / effect.allDisplaysDistance); + + // distance needed for the FOV-sized monitor to fill up the screen + const fullScreenDistance = viewportHeight / 2 / Math.tan(fovRadians.vertical / 2); + const lensDistancePixels = fullScreenDistance / (1.0 - lensDistanceRatio) - fullScreenDistance; + + // distance of a display at the default (most zoomed out) distance, plus the lens distance constant + const lensToScreenDistance = viewportHeight / 2 / Math.tan(defaultDistanceVerticalRadians / 2); + const completeScreenDistancePixels = lensToScreenDistance + lensDistancePixels; + + return { + widthPixels: viewportWidth, + heightPixels: viewportHeight, + defaultDistanceVerticalRadians, + defaultDistanceHorizontalRadians, + lensDistancePixels, + completeScreenDistancePixels, + monitorWrappingScheme: actualWrapScheme(screens, viewportWidth, viewportHeight), + curvedDisplay: false // or true + }; + } + + // Utility constant + readonly property real segmentsPerRadian: 20.0 / degreeToRadian(90.0) + + // FOV conversion functions for flat and curved displays + property var fovConversionFns: ({ + flat: { + centerToFovEdgeDistance: function(centerDistance, fovLength) { + return Math.sqrt(Math.pow(fovLength / 2, 2) + Math.pow(centerDistance, 2)); + }, + fovEdgeToScreenCenterDistance: function(edgeDistance, screenLength) { + return Math.sqrt(Math.pow(edgeDistance, 2) - Math.pow(screenLength / 2, 2)); + }, + lengthToRadians: function(fovRadians, fovLength, screenEdgeDistance, toLength) { + return Math.asin(toLength / 2 / screenEdgeDistance) * 2; + }, + angleToLength: function(fovRadians, fovLength, screenDistance, toAngleOpposite, toAngleAdjacent) { + return toAngleOpposite / toAngleAdjacent * screenDistance; + }, + radiansToSegments: function(screenRadians) { return 1; } + }, + curved: { + centerToFovEdgeDistance: function(centerDistance, fovLength) { + return centerDistance; + }, + fovEdgeToScreenCenterDistance: function(edgeDistance, screenLength) { + return edgeDistance; + }, + lengthToRadians: function(fovRadians, fovLength, screenEdgeDistance, toLength) { + return fovRadians / fovLength * toLength; + }, + angleToLength: function(fovRadians, fovLength, screenDistance, toAngleOpposite, toAngleAdjacent) { + return fovLength / fovRadians * Math.atan2(toAngleOpposite, toAngleAdjacent); + }, + radiansToSegments: function(screenRadians) { + return Math.ceil(screenRadians * segmentsPerRadian); + } + } + }) + + function monitorWrap(cachedMonitorRadians, monitorSpacingPixels, monitorBeginPixel, monitorLengthPixels, lengthToRadianFn) { + var closestWrapPixel = monitorBeginPixel; + var closestWrap = cachedMonitorRadians[monitorBeginPixel]; + if (closestWrap === undefined) { + var keys = Object.keys(cachedMonitorRadians); + closestWrapPixel = keys.reduce(function(previousPixel, currentPixel) { + if (previousPixel === undefined) return currentPixel; + + var currentDelta = currentPixel - monitorBeginPixel; + var previousDelta = previousPixel - monitorBeginPixel; + + if (previousDelta % monitorLengthPixels !== 0) { + if (currentDelta % monitorLengthPixels === 0) return currentPixel; + if (previousDelta < 0 && currentDelta > 0) return currentPixel; + if (Math.abs(currentDelta) < Math.abs(previousDelta)) return currentPixel; + } + return previousPixel; + }, undefined); + closestWrap = cachedMonitorRadians[closestWrapPixel]; + } + + var spacingRadians = lengthToRadianFn(monitorSpacingPixels); + if (closestWrapPixel !== monitorBeginPixel) { + var gapPixels = monitorBeginPixel - closestWrapPixel; + var gapRadians = lengthToRadianFn(gapPixels); + var appliedSpacingRadians = Math.floor(gapPixels / monitorLengthPixels) * spacingRadians; + closestWrap = closestWrap + gapRadians + appliedSpacingRadians; + closestWrapPixel = monitorBeginPixel; + cachedMonitorRadians[closestWrapPixel] = closestWrap; + } + + var monitorRadians = lengthToRadianFn(monitorLengthPixels); + var centerRadians = closestWrap + monitorRadians / 2; + var endRadians = closestWrap + monitorRadians; + + var nextMonitorPixel = monitorBeginPixel + monitorLengthPixels; + if (cachedMonitorRadians[nextMonitorPixel] === undefined) + cachedMonitorRadians[nextMonitorPixel] = endRadians + spacingRadians; + + return { + begin: closestWrap, + center: centerRadians, + end: endRadians + } + } + + function horizontalMonitorSort(monitors) { + return monitors.map(function(monitor, index) { + return { originalIndex: index, monitorDetails: monitor }; + }).sort(function(a, b) { + var aMon = a.monitorDetails; + var bMon = b.monitorDetails; + if (aMon.y !== bMon.y) return aMon.y - bMon.y; + return aMon.x - bMon.x; + }); + } + + function verticalMonitorSort(monitors) { + return monitors.map(function(monitor, index) { + return { originalIndex: index, monitorDetails: monitor }; + }).sort(function(a, b) { + var aMon = a.monitorDetails; + var bMon = b.monitorDetails; + if (aMon.x !== bMon.x) return aMon.x - bMon.x; + return aMon.y - bMon.y; + }); + } + + // fovDetails: { widthPixels, heightPixels, defaultDistanceHorizontalRadians, defaultDistanceVerticalRadians, completeScreenDistancePixels, monitorWrappingScheme, curvedDisplay } + // monitorDetailsList: [{x, y, width, height}, ...] + // monitorSpacing: number (percentage, e.g. 0.05 for 5%) + function monitorsToPlacements(fovDetails, monitorDetailsList, monitorSpacing) { + var monitorPlacements = []; + var cachedMonitorRadians = {}; + + var conversionFns = fovDetails.curvedDisplay ? fovConversionFns.curved : fovConversionFns.flat; + + if (fovDetails.monitorWrappingScheme === 'horizontal') { + var sideEdgeRadius = conversionFns.centerToFovEdgeDistance(fovDetails.completeScreenDistancePixels, fovDetails.widthPixels); + var monitorSpacingPixels = monitorSpacing * fovDetails.widthPixels; + var lengthToRadianFn = function(targetWidth) { + return conversionFns.lengthToRadians( + fovDetails.defaultDistanceHorizontalRadians, + fovDetails.widthPixels, + sideEdgeRadius, + targetWidth + ); + }; + + cachedMonitorRadians[0] = -fovDetails.defaultDistanceHorizontalRadians / 2; + horizontalMonitorSort(monitorDetailsList).forEach(function(obj) { + var monitorDetails = obj.monitorDetails; + var originalIndex = obj.originalIndex; + var monitorWrapDetails = monitorWrap(cachedMonitorRadians, monitorSpacingPixels, monitorDetails.x, monitorDetails.width, lengthToRadianFn); + var monitorCenterRadius = conversionFns.fovEdgeToScreenCenterDistance(sideEdgeRadius, monitorDetails.width); + var upTopPixels = -monitorDetails.y - (monitorDetails.y / fovDetails.heightPixels) * monitorSpacingPixels; + var upCenterOffsetPixels = (monitorDetails.height - fovDetails.heightPixels) / 2; + var upCenterPixels = upTopPixels - upCenterOffsetPixels; + + monitorPlacements.push({ + originalIndex: originalIndex, + centerNoRotate: Qt.vector3d( + monitorCenterRadius, + 0, + upCenterPixels + ), + centerLook: Qt.vector3d( + monitorCenterRadius * Math.cos(monitorWrapDetails.center), + -monitorCenterRadius * Math.sin(monitorWrapDetails.center), + upCenterPixels + ).normalized(), + rotationAngleRadians: { + x: 0, + y: -monitorWrapDetails.center + } + }); + }); + } else if (fovDetails.monitorWrappingScheme === 'vertical') { + var topEdgeRadius = conversionFns.centerToFovEdgeDistance(fovDetails.completeScreenDistancePixels, fovDetails.heightPixels); + var monitorSpacingPixels = monitorSpacing * fovDetails.heightPixels; + var lengthToRadianFn = function(targetHeight) { + return conversionFns.lengthToRadians( + fovDetails.defaultDistanceVerticalRadians, + fovDetails.heightPixels, + topEdgeRadius, + targetHeight + ); + }; + + cachedMonitorRadians[0] = -fovDetails.defaultDistanceVerticalRadians / 2; + verticalMonitorSort(monitorDetailsList).forEach(function(obj) { + var monitorDetails = obj.monitorDetails; + var originalIndex = obj.originalIndex; + var monitorWrapDetails = monitorWrap(cachedMonitorRadians, monitorSpacingPixels, monitorDetails.y, monitorDetails.height, lengthToRadianFn); + var monitorCenterRadius = conversionFns.fovEdgeToScreenCenterDistance(topEdgeRadius, monitorDetails.height); + var westLeftPixels = -monitorDetails.x - (monitorDetails.x / fovDetails.widthPixels) * monitorSpacingPixels; + var westCenterOffsetPixels = (monitorDetails.width - fovDetails.widthPixels) / 2; + var westCenterPixels = westLeftPixels - westCenterOffsetPixels; + + monitorPlacements.push({ + originalIndex: originalIndex, + centerNoRotate: Qt.vector3d( + monitorCenterRadius, + westCenterPixels, + 0 + ), + centerLook: Qt.vector3d( + monitorCenterRadius * Math.cos(monitorWrapDetails.center), + westCenterPixels, + -monitorCenterRadius * Math.sin(monitorWrapDetails.center) + ).normalized(), + rotationAngleRadians: { + x: -monitorWrapDetails.center, + y: 0 + } + }); + }); + } else { + var monitorSpacingPixels = monitorSpacing * fovDetails.widthPixels; + monitorDetailsList.forEach(function(monitorDetails, index) { + var upTopPixels = -monitorDetails.y - (monitorDetails.y / fovDetails.heightPixels) * monitorSpacingPixels; + var westLeftPixels = -monitorDetails.x - (monitorDetails.x / fovDetails.widthPixels) * monitorSpacingPixels; + var westCenterOffsetPixels = (monitorDetails.width - fovDetails.widthPixels) / 2; + var upCenterOffsetPixels = (monitorDetails.height - fovDetails.heightPixels) / 2; + var westCenterPixels = westLeftPixels - westCenterOffsetPixels; + var upCenterPixels = upTopPixels - upCenterOffsetPixels; + + monitorPlacements.push({ + originalIndex: index, + centerNoRotate: Qt.vector3d( + fovDetails.completeScreenDistancePixels, + westCenterPixels, + upCenterPixels + ), + centerLook: Qt.vector3d( + fovDetails.completeScreenDistancePixels, + westCenterPixels, + upCenterPixels + ).normalized(), + rotationAngleRadians: { + x: 0, + y: 0 + } + }); + }); + } + + // put them back in the original monitor order before returning + monitorPlacements.sort(function(a, b) { return a.originalIndex - b.originalIndex; }); + + return monitorPlacements; + } + + // returns how far the look vector is from the center of the monitor, as a percentage of the monitor's dimensions + function getMonitorDistance(fovDetails, lookUpPixels, lookWestPixels, monitorVector, monitorDetails, upAngleToLength, westAngleToLength) { + var vectorUpPixels = upAngleToLength( + fovDetails.defaultDistanceVerticalRadians, + fovDetails.heightPixels, + fovDetails.completeScreenDistancePixels, + monitorVector.z, + monitorVector.x + ); + var upPercentage = Math.abs(lookUpPixels - vectorUpPixels) / monitorDetails.height; + + var vectorWestPixels = westAngleToLength( + fovDetails.defaultDistanceHorizontalRadians, + fovDetails.widthPixels, + fovDetails.completeScreenDistancePixels, + monitorVector.y, + monitorVector.x + ); + var westPercentage = Math.abs(lookWestPixels - vectorWestPixels) / monitorDetails.width; + + // how close we are to any edge is the largest of the two percentages + return Math.max(upPercentage, westPercentage); + } + + function findFocusedMonitor(quaternion, monitorVectors, currentFocusedIndex, smoothFollowEnabled, fovDetails, monitorsDetails) { + var lookVector = Qt.vector3d(1.0, 0.0, 0.0); // NWU vector pointing to the center of the screen + var rotatedLookVector = quaternion.times(lookVector); + + // Use curved or flat conversion functions depending on wrapping scheme + var upConversionFns = fovDetails.monitorWrappingScheme === "vertical" ? fovConversionFns.curved : fovConversionFns.flat; + var lookUpPixels = upConversionFns.angleToLength( + fovDetails.defaultDistanceVerticalRadians, + fovDetails.heightPixels, + fovDetails.completeScreenDistancePixels, + rotatedLookVector.z, + rotatedLookVector.x + ); + var westConversionFns = fovDetails.monitorWrappingScheme === "horizontal" ? fovConversionFns.curved : fovConversionFns.flat; + var lookWestPixels = westConversionFns.angleToLength( + fovDetails.defaultDistanceHorizontalRadians, + fovDetails.widthPixels, + fovDetails.completeScreenDistancePixels, + rotatedLookVector.y, + rotatedLookVector.x + ); + + // Check current focused monitor first + if (currentFocusedIndex !== -1) { + var focusedDistance = getMonitorDistance( + fovDetails, + lookUpPixels, + lookWestPixels, + monitorVectors[currentFocusedIndex], + monitorsDetails[currentFocusedIndex], + upConversionFns.angleToLength, + westConversionFns.angleToLength + ) * effect.focusedDisplayDistance / effect.allDisplaysDistance; + + if (smoothFollowEnabled || focusedDistance < unfocusThreshold) + return currentFocusedIndex; + } + + var closestIndex = -1; + var closestDistance = Number.POSITIVE_INFINITY; + + // Find the closest monitor + for (var i = 0; i < monitorVectors.length; ++i) { + if (i === currentFocusedIndex) + continue; + var distance = getMonitorDistance( + fovDetails, + lookUpPixels, + lookWestPixels, + monitorVectors[i], + monitorsDetails[i], + upConversionFns.angleToLength, + westConversionFns.angleToLength + ); + + if (distance < closestDistance) { + closestIndex = i; + closestDistance = distance; + } + } + + if (smoothFollowEnabled || closestDistance < focusThreshold) + return closestIndex; + + // Unfocus all displays + return -1; + } +} \ No newline at end of file diff --git a/kwin/src/qml/main.qml b/kwin/src/qml/main.qml new file mode 100644 index 0000000..d2ad25a --- /dev/null +++ b/kwin/src/qml/main.qml @@ -0,0 +1,122 @@ +import QtQuick +import QtQuick3D +import org.kde.kwin as KWinComponents +import org.kde.kwin.effect.breezy_desktop + +Item { + id: root + antialiasing: true + focus: false + + readonly property var supportedModels: [ + "VITURE", + "nreal air", + "Air", + "Air 2", + "Air 2 Pro", + "Air 2 Ultra", + "SmartGlasses", // TCL/RayNeo + "Rokid Max", + "Rokid Air" + ] + required property QtObject effect + required property QtObject targetScreen + + property real viewportDiagonalFOVDegrees: effect.diagonalFOV + property var viewportResolution: effect.displayResolution + property var screens: KWinComponents.Workspace.screens + // .filter(function(screen) { + // return supportedModels.includes(screen.model); + // }) + + // x value for placing the viewport in the middle of all screens + property real screensXMid: { + let xMin = Number.MAX_VALUE; + let xMax = Number.MIN_VALUE; + + for (let i = 0; i < screens.length; i++) { + const geometry = screens[i].geometry; + xMin = Math.min(xMin, geometry.x); + xMax = Math.max(xMax, geometry.x + geometry.width); + } + + return (xMin + xMax) / 2 - (viewportResolution[0] / 2); + } + + // y value for placing the viewport in the middle of all screens + property real screensYMid: { + let yMin = Number.MAX_VALUE; + let yMax = Number.MIN_VALUE; + + for (let i = 0; i < screens.length; i++) { + const geometry = screens[i].geometry; + yMin = Math.min(yMin, geometry.y); + yMax = Math.max(yMax, geometry.y + geometry.height); + } + + return (yMin + yMax) / 2 - (viewportResolution[1] / 2); + } + + Displays { + id: displays + } + + property var fovDetails: displays.fovDetails(screens, viewportResolution[0], viewportResolution[1], viewportDiagonalFOVDegrees, effect.lensDistanceRatio) + + property var monitorPlacements: { + const adjustedGeometries = screens.map(screen => { + const g = screen.geometry; + return { + x: g.x - screensXMid, + y: g.y - screensYMid, + width: g.width, + height: g.height + }; + }); + return displays.monitorsToPlacements(fovDetails, adjustedGeometries, effect.displaySpacing); + } + + Component { + id: desktopViewComponent + DesktopView { + screen: root.targetScreen + width: root.targetScreen.geometry.width + height: root.targetScreen.geometry.height + } + } + + Component { + id: view3DComponent + View3D { + anchors.fill: parent + environment: SceneEnvironment { + antialiasingMode: SceneEnvironment.SSAA + antialiasingQuality: SceneEnvironment.VeryHigh + } + + PerspectiveCamera { + id: camera + } + + BreezyDesktop { + id: breezyDesktop + } + + CameraController { + id: cameraController + anchors.fill: parent + camera: camera + } + } + } + + Loader { + id: viewLoader + anchors.fill: parent + } + + Component.onCompleted: { + const targetScreenSupported = supportedModels.some(model => root.targetScreen.model.endsWith(model)); + viewLoader.sourceComponent = targetScreenSupported ? view3DComponent : desktopViewComponent; + } +} diff --git a/kwin/src/xrdriveripc/CMakeLists.txt b/kwin/src/xrdriveripc/CMakeLists.txt new file mode 100644 index 0000000..81887b6 --- /dev/null +++ b/kwin/src/xrdriveripc/CMakeLists.txt @@ -0,0 +1,26 @@ +add_library(xr_driver_ipc STATIC + xrdriveripc.cpp +) + +# Ensure position independent code so the static archive can link into the KWin effect plugin (a shared module) +set_target_properties(xr_driver_ipc PROPERTIES POSITION_INDEPENDENT_CODE ON) + +# Generate an export header so symbols can be visible outside the shared lib +include(GenerateExportHeader) +generate_export_header(xr_driver_ipc EXPORT_FILE_NAME xr_driver_ipc_export.h) + +target_include_directories(xr_driver_ipc + PUBLIC + ${CMAKE_CURRENT_BINARY_DIR} # for generated export header +) + +target_compile_options(xr_driver_ipc PRIVATE + $<$:/EHsc> + $<$>:-fexceptions> +) + +target_link_libraries(xr_driver_ipc + PRIVATE Qt6::Core +) + +install(FILES xrdriveripc.py xrdriveripc_runner.py DESTINATION ${KDE_INSTALL_DATADIR}/kwin/effects/breezy_desktop) \ No newline at end of file diff --git a/kwin/src/xrdriveripc/xrdriveripc.cpp b/kwin/src/xrdriveripc/xrdriveripc.cpp new file mode 100644 index 0000000..bad7835 --- /dev/null +++ b/kwin/src/xrdriveripc/xrdriveripc.cpp @@ -0,0 +1,142 @@ +// New implementation using QProcess to call python +#include "xrdriveripc.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +XRDriverIPC &XRDriverIPC::instance() { + static XRDriverIPC inst; + if (!inst.m_initialized) { + QString installedFile = QStandardPaths::locate( + QStandardPaths::GenericDataLocation, + QStringLiteral("kwin/effects/breezy_desktop/xrdriveripc.py"), + QStandardPaths::LocateFile); + if (installedFile.isEmpty()) { + throw std::runtime_error("Cannot locate kwin/effects/breezy_desktop/xrdriveripc.py"); + } + inst.m_pythonDir = QFileInfo(installedFile).path(); + inst.m_initialized = true; + } + return inst; +} + +std::string XRDriverIPC::string(const XRDict &dict, const std::string &key) { + auto it = dict.find(key); + if (it != dict.end() && std::holds_alternative(it->second)) { + return std::get(it->second); + } + return {}; +} + +std::string XRDriverIPC::configHome() const { + QString configHome = QString::fromUtf8(qgetenv("XDG_CONFIG_HOME")); + if (configHome.isEmpty()) { + QString homeDir = QString::fromUtf8(qgetenv("HOME")); + configHome = homeDir + QStringLiteral("/.config"); + } + return configHome.toStdString(); +} + +QByteArray XRDriverIPC::invokePython(const QString &method, + const QByteArray &payloadJson, + const QString &singleArg) const { + QProcess proc; + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + env.insert(QStringLiteral("BREEZY_METHOD"), method); + env.insert(QStringLiteral("BREEZY_CONFIG_HOME"), QString::fromStdString(configHome())); + if (!singleArg.isEmpty()) env.insert(QStringLiteral("BREEZY_ARG"), singleArg); + if (!payloadJson.isEmpty()) env.insert(QStringLiteral("BREEZY_PAYLOAD"), QString::fromUtf8(payloadJson)); + proc.setProcessEnvironment(env); + // Expect xrdriveripc_runner.py to reside in the same directory as xrdriveripc.py (m_pythonDir) + QString wrapperPath = m_pythonDir + QStringLiteral("/xrdriveripc_runner.py"); + proc.start(QStringLiteral("python3"), QStringList() << wrapperPath); + if (!proc.waitForStarted(5000)) { + std::cerr << "Failed to start python process" << std::endl; + return {}; + } + proc.closeWriteChannel(); + if (!proc.waitForFinished(15000)) { + proc.kill(); + std::cerr << "Python process timeout" << std::endl; + return {}; + } + if (proc.exitStatus() != QProcess::NormalExit || proc.exitCode() != 0) { + std::cerr << "Python process failed (" << proc.exitCode() << "):\n" + << proc.readAllStandardError().toStdString() << std::endl; + return {}; + } + return proc.readAllStandardOutput().trimmed(); +} + +static XRDict jsonToXRDict(const QJsonObject &obj) { + XRDict out; + for (auto it = obj.begin(); it != obj.end(); ++it) { + const QString &k = it.key(); + const QJsonValue &v = it.value(); + if (v.isBool()) out[k.toStdString()] = v.toBool(); + else if (v.isDouble() && std::floor(v.toDouble()) == v.toDouble()) + out[k.toStdString()] = (int)v.toDouble(); + else if (v.isDouble()) out[k.toStdString()] = v.toDouble(); + else if (v.isString()) out[k.toStdString()] = v.toString().toStdString(); + else out[k.toStdString()] = std::monostate{}; + } + return out; +} + +std::optional XRDriverIPC::retrieveConfig() { + QByteArray out = invokePython(QStringLiteral("retrieve_config"), {}, QStringLiteral("1")); + if (out.isEmpty()) return std::nullopt; + QJsonParseError err; auto doc = QJsonDocument::fromJson(out, &err); + if (err.error != QJsonParseError::NoError || !doc.isObject()) return std::nullopt; + return jsonToXRDict(doc.object()); +} + +std::optional XRDriverIPC::retrieveDriverState() { + QByteArray out = invokePython(QStringLiteral("retrieve_driver_state"), {}, {}); + if (out.isEmpty()) return std::nullopt; + QJsonParseError err; auto doc = QJsonDocument::fromJson(out, &err); + if (err.error != QJsonParseError::NoError || !doc.isObject()) return std::nullopt; + return jsonToXRDict(doc.object()); +} + +bool XRDriverIPC::writeConfig(const XRDict &configUpdate) { + QJsonObject obj; + for (const auto &kv : configUpdate) { + const std::string &k = kv.first; const XRValue &v = kv.second; + if (std::holds_alternative(v)) obj.insert(QString::fromStdString(k), std::get(v)); + else if (std::holds_alternative(v)) obj.insert(QString::fromStdString(k), std::get(v)); + else if (std::holds_alternative(v)) obj.insert(QString::fromStdString(k), std::get(v)); + else if (std::holds_alternative(v)) obj.insert(QString::fromStdString(k), QString::fromStdString(std::get(v))); + } + QByteArray payload = QJsonDocument(obj).toJson(QJsonDocument::Compact); + QByteArray out = invokePython(QStringLiteral("write_config"), payload, {}); + return !out.isEmpty(); +} + +bool XRDriverIPC::writeControlFlags(const std::map &flags) { + QJsonObject obj; for (const auto &kv : flags) obj.insert(QString::fromStdString(kv.first), kv.second); + QByteArray payload = QJsonDocument(obj).toJson(QJsonDocument::Compact); + QByteArray out = invokePython(QStringLiteral("write_control_flags"), payload, {}); + return !out.isEmpty(); +} + +bool XRDriverIPC::requestToken(const std::string &email) { + QByteArray out = invokePython(QStringLiteral("request_token"), {}, QString::fromStdString(email)); + if (out.isEmpty()) return false; + auto value = QJsonValue(QString::fromStdString(out.toStdString())); + return value.isBool() ? value.toBool() : false; +} + +bool XRDriverIPC::verifyToken(const std::string &token) { + QByteArray out = invokePython(QStringLiteral("verify_token"), {}, QString::fromStdString(token)); + if (out.isEmpty()) return false; + auto value = QJsonValue(QString::fromStdString(out.toStdString())); + return value.isBool() ? value.toBool() : false; +} diff --git a/kwin/src/xrdriveripc/xrdriveripc.h b/kwin/src/xrdriveripc/xrdriveripc.h new file mode 100644 index 0000000..ff6d654 --- /dev/null +++ b/kwin/src/xrdriveripc/xrdriveripc.h @@ -0,0 +1,111 @@ +// C++ bridge now invoking xrdriveripc via external python process +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +// Export header generated by CMake (GenerateExportHeader) +#ifdef __has_include +# if __has_include("xr_driver_ipc_export.h") +# include "xr_driver_ipc_export.h" +# endif +#endif + +#ifndef XR_DRIVER_IPC_EXPORT +# define XR_DRIVER_IPC_EXPORT __attribute__((visibility("default"))) +#endif + +namespace XRStateEntry { + inline constexpr const char *Heartbeat = "heartbeat"; + inline constexpr const char *HardwareId = "hardware_id"; + inline constexpr const char *ConnectedDeviceBrand = "connected_device_brand"; + inline constexpr const char *ConnectedDeviceModel = "connected_device_model"; + inline constexpr const char *MagnetSupported = "magnet_supported"; + inline constexpr const char *MagnetCalibrationType = "magnet_calibration_type"; + inline constexpr const char *UsingMagnet = "using_magnet"; + inline constexpr const char *MagnetStale = "magnet_stale"; + inline constexpr const char *MagnetCalibrating = "magnet_calibrating"; + inline constexpr const char *GyroCalibrating = "gyro_calibrating"; + inline constexpr const char *AccelCalibrating = "accel_calibrating"; + inline constexpr const char *SbsModeEnabled = "sbs_mode_enabled"; + inline constexpr const char *SbsModeSupported = "sbs_mode_supported"; + inline constexpr const char *FirmwareUpdateRecommended = "firmware_update_recommended"; + inline constexpr const char *BreezyDesktopSmoothFollowEnabled = "breezy_desktop_smooth_follow_enabled"; + inline constexpr const char *IsGamescopeReshadeIPCConnected = "is_gamescope_reshade_ipc_connected"; + inline constexpr const char *UiView = "ui_view_enabled"; + + namespace UIView { + inline constexpr const char *DriverRunning = "driver_running"; + + namespace License { + inline constexpr const char *Tiers = "tiers"; + inline constexpr const char *Features = "features"; + inline constexpr const char *HardwareId = "hardware_id"; + inline constexpr const char *ConfirmedToken = "confirmed_token"; + inline constexpr const char *ActionNeeded = "action_needed"; + inline constexpr const char *EnabledFeatures = "enabled_features"; + } + } +} + +namespace XRConfigEntry { + inline constexpr const char *Disabled = "disabled"; + inline constexpr const char *GamescopeReshadeWaylandDisabled = "gamescope_reshade_wayland_disabled"; + inline constexpr const char *OutputMode = "output_mode"; + inline constexpr const char *ExternalMode = "external_mode"; + inline constexpr const char *MouseSensitivity = "mouse_sensitivity"; + inline constexpr const char *DisplayZoom = "display_zoom"; + inline constexpr const char *LookAhead = "look_ahead"; + inline constexpr const char *SbsDisplaySize = "sbs_display_size"; + inline constexpr const char *SbsDisplayDistance = "sbs_display_distance"; + inline constexpr const char *SbsContent = "sbs_content"; + inline constexpr const char *SbsModeStretched = "sbs_mode_stretched"; + inline constexpr const char *SideviewPosition = "sideview_position"; + inline constexpr const char *SideviewDisplaySize = "sideview_display_size"; + inline constexpr const char *VirtualDisplaySmoothFollowEnabled = "virtual_display_smooth_follow_enabled"; + inline constexpr const char *SideviewSmoothFollowEnabled = "sideview_smooth_follow_enabled"; + inline constexpr const char *SideviewFollowThreshold = "sideview_follow_threshold"; + inline constexpr const char *CurvedDisplay = "curved_display"; + inline constexpr const char *MultiTapEnabled = "multi_tap_enabled"; + inline constexpr const char *SmoothFollowTrackRoll = "smooth_follow_track_roll"; + inline constexpr const char *SmoothFollowTrackPitch = "smooth_follow_track_pitch"; + inline constexpr const char *SmoothFollowTrackYaw = "smooth_follow_track_yaw"; + inline constexpr const char *Debug = "debug"; +} + +// Simple variant type for config/state key values we care about +using XRValue = std::variant; +using XRDict = std::map; + +class XR_DRIVER_IPC_EXPORT XRDriverIPC { +public: + static XRDriverIPC &instance(); + static std::string string(const XRDict &dict, const std::string &key); + + std::optional retrieveConfig(); + std::optional retrieveDriverState(); + bool writeConfig(const XRDict &configUpdate); + bool writeControlFlags(const std::map &flags); + bool requestToken(const std::string &email); + bool verifyToken(const std::string &token); + + +private: + XRDriverIPC() = default; + ~XRDriverIPC() = default; + XRDriverIPC(const XRDriverIPC&) = delete; + XRDriverIPC& operator=(const XRDriverIPC&) = delete; + + std::string configHome() const; + QByteArray invokePython(const QString &method, + const QByteArray &payloadJson, + const QString &singleArg) const; + + bool m_initialized = false; + QString m_pythonDir; // directory containing xrdriveripc.py +}; diff --git a/kwin/src/xrdriveripc/xrdriveripc_runner.py b/kwin/src/xrdriveripc/xrdriveripc_runner.py new file mode 100644 index 0000000..748bc70 --- /dev/null +++ b/kwin/src/xrdriveripc/xrdriveripc_runner.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Wrapper script invoked by xrdriveripc.cpp via QProcess. + +It reads environment variables to determine which XRDriverIPC method to call +and prints the JSON-serialized result to stdout, mirroring the prior inline +python one-liner implementation. +""" + +from __future__ import annotations + +import json +import os +import sys +import traceback + + +def main() -> int: + # Ensure the current directory (where xrdriveripc.py lives) is in sys.path + script_dir = os.path.dirname(os.path.abspath(__file__)) + if script_dir not in sys.path: + sys.path.insert(0, script_dir) + + try: + import xrdriveripc # type: ignore + except Exception as e: # pragma: no cover - import failure path + print("Failed to import xrdriveripc: %s" % e, file=sys.stderr) + return 2 + + method = os.environ.get("BREEZY_METHOD") + if not method: + print("BREEZY_METHOD not set", file=sys.stderr) + return 2 + + config_home = os.environ.get("BREEZY_CONFIG_HOME") + inst = xrdriveripc.XRDriverIPC(config_home=config_home) + + arg = os.environ.get("BREEZY_ARG") + payload_raw = os.environ.get("BREEZY_PAYLOAD") + + # Dispatch replicating previous inline logic + try: + if method == "retrieve_config": + res = getattr(inst, method)(int(arg) if arg else 1) + elif method in ("write_config", "write_control_flags") and payload_raw: + res = getattr(inst, method)(json.loads(payload_raw)) + elif method in ("request_token", "verify_token") and arg: + res = getattr(inst, method)(arg) + else: + res = getattr(inst, method)() + except Exception: # pragma: no cover - runtime failure path + traceback.print_exc() + return 3 + + try: + print(json.dumps(res)) + except Exception: # pragma: no cover + traceback.print_exc() + return 3 + return 0 + + +if __name__ == "__main__": # pragma: no cover + sys.exit(main())