Add KDE port (#129)

Working KWIn plugin for SteamOS and non-Debian distros (x86_64 only for now)
This commit is contained in:
Wayne Heaney 2025-08-30 21:25:03 -07:00 committed by GitHub
parent ecd75cb799
commit 449fda2e9e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 3109 additions and 6 deletions

1
.gitignore vendored
View File

@ -4,3 +4,4 @@ __pycache__
gschemas.compiled
out/
*.po~
kwin/src/xrdriveripc/xrdriveripc.py

View File

@ -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

79
bin/breezy_kwin_setup Executable file
View File

@ -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

View File

@ -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"

82
bin/package_kwin Executable file
View File

@ -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

View File

@ -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)
}
}

55
kwin/CMakeLists.txt Normal file
View File

@ -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)

56
kwin/bin/breezy_kwin_uninstall Executable file
View File

@ -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

23
kwin/bin/package_kwin_plugin Executable file
View File

@ -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

96
kwin/bin/setup Executable file
View File

@ -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" <<EOF
# Added by Breezy Desktop installer: QT plugin path setup
if [ -n "\$QT_PLUGIN_PATH" ]; then
export QT_PLUGIN_PATH="$QT_PLUGIN_DIR:\$QT_PLUGIN_PATH"
else
export QT_PLUGIN_PATH="$QT_PLUGIN_DIR"
fi
export QT_DEBUG_PLUGINS=1
EOF
fi
# set up the XR driver using the local binary
echo "Installing xrDriver (requires sudo)"
echo "BEGIN - xr_driver_setup"
if [ -z "$1" ]
then
sudo bin/xr_driver_setup $(pwd)/xrDriver.tar.gz
else
sudo bin/xr_driver_setup -v $1 $(pwd)/xrDriver.tar.gz
fi
echo "END - xr_driver_setup"
printf "\n\033[1;33m!!! IMPORTANT !!!\033[0m You must log out and back in, then enable Breezy Desktop from the Desktop Effects in System Settings\n\n"

View File

@ -0,0 +1,15 @@
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
set(CMAKE_INSTALL_PREFIX "/usr" CACHE PATH "Force path to set CMAKE_INSTALL_PREFIX" FORCE)
endif(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
if(NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE "Release" CACHE STRING "Choose Release or Debug" FORCE)
endif()
set(CMAKE_CXX_FLAGS_RELEASE "${CMAKE_CXX_FLAGS_RELEASE} -DQT_NO_DEBUG_OUTPUT")
set(QT_MIN_VERSION "6.4.0")
set(QT_MAJOR_VERSION 6)
set(KF_MIN_VERSION 6)

24
kwin/cmake/info.cmake Normal file
View File

@ -0,0 +1,24 @@
if(${KF_MIN_VERSION} EQUAL 6)
set(KWIN_EFFECT_INCLUDE_FILE "/usr/include/kwin/effect/effect.h")
else ()
set(KWIN_EFFECT_INCLUDE_FILE "/usr/include/kwineffects.h")
endif ()
execute_process(
COMMAND sh -c "grep '#define KWIN_EFFECT_API_VERSION_MINOR' ${KWIN_EFFECT_INCLUDE_FILE} | awk '{print \$NF}'"
OUTPUT_VARIABLE KWIN_EFFECT_API_VERSION_MINOR OUTPUT_STRIP_TRAILING_WHITESPACE
)
message(STATUS "Found KWinEffect API Version: ${KWIN_EFFECT_API_VERSION_MINOR}")
#below is a very useful way of finding variables and contains:
#get_cmake_property(_variableNames VARIABLES)
#list (SORT _variableNames)
#foreach (_variableName ${_variableNames})
# string(TOLOWER "${_variableName}" KEY)
# string(TOLOWER "${${_variableName}}" VALUE)
# string(FIND "${KEY}" "kwin" INDEX1)
# string(FIND "${VALUE}" "kwin" INDEX2)
# if (${INDEX1} GREATER -1 OR ${INDEX2} GREATER -1)
# message(STATUS "VARIABLE ${_variableName}=${${_variableName}}")
# endif ()
#endforeach()

18
kwin/cmake/pack.cmake Normal file
View File

@ -0,0 +1,18 @@
# these are cache variables, so they could be overwritten with -D,
set(CPACK_PACKAGE_NAME ${CMAKE_PROJECT_NAME} CACHE STRING ${CMAKE_PROJECT_NAME})
set(CPACK_PACKAGING_INSTALL_PREFIX "/usr")
set(CPACK_PACKAGE_FILE_NAME "${CMAKE_PROJECT_NAME}")
set(CPACK_PACKAGE_VERSION "${CMAKE_PROJECT_VERSION}")
set(CPACK_PACKAGE_DESCRIPTION_SUMMARY "Breezy Desktop - KWin Plugin")
set(CPACK_PACKAGE_CONTACT "wayne@xronlinux.com")
set(CPACK_DEBIAN_PACKAGE_MAINTAINER "Wayne Heaney")
set(CPACK_DEBIAN_PACKAGE_SECTION "kde")
# autogenerate dependency information
set(CPACK_DEBIAN_PACKAGE_SHLIBDEPS ON)
set(CPACK_DEBIAN_PACKAGE_GENERATE_SHLIBS ON)
set(CPACK_DEBIAN_PACKAGE_GENERATE_SHLIBS_POLICY "=")
include(CPack)
# To generate deb files, install 'dpkg-dev' package and then run 'cpack -G DEB'

View File

@ -0,0 +1,7 @@
find_package(KF6 QUIET COMPONENTS ConfigWidgets)
if(${KF6_FOUND} EQUAL 0)
set(QT_MIN_VERSION "5.15")
set(QT_MAJOR_VERSION 5)
set(KF_MIN_VERSION "5.78")
endif ()

2
kwin/cmake/test.cmake Normal file
View File

@ -0,0 +1,2 @@
add_test (NAME KWinEffectSupport COMMAND sh ${CMAKE_CURRENT_SOURCE_DIR}/tools/isSupported.sh)
set_property (TEST KWinEffectSupport PROPERTY PASS_REGULAR_EXPRESSION "true")

View File

@ -0,0 +1,36 @@
# To run the build from the package root:
# docker buildx build --platform linux/amd64,linux/arm64 -f ./docker-build/Dockerfile -t "breezy-kwin" .
# docker run --rm -t -v ./:/source -v --platform linux/amd64 "breezy-kwin:amd64"
# docker run --rm -t -v ./:/source -v --platform linux/arm64 "breezy-kwin:arm64"
FROM --platform=$TARGETPLATFORM archlinux:base-20250817.0.405639@sha256:31f0749bdb81517dc8f379feac0a3860b097f1da1f53c8315c1bae0817d6c0a1
ARG TARGETPLATFORM
RUN echo "Target platform: $TARGETPLATFORM"
RUN pacman -Sy --noconfirm --needed \
ca-certificates \
base-devel \
cmake \
pkgconf \
git \
curl \
wget \
extra-cmake-modules \
qt6-base \
qt6-declarative \
qt6-tools \
kconfig \
kconfigwidgets \
kcoreaddons \
kglobalaccel \
ki18n \
kcmutils \
kxmlgui \
kwindowsystem \
kwin \
&& pacman -Scc --noconfirm
WORKDIR /source
CMD bin/package_kwin_plugin

View File

@ -0,0 +1,37 @@
# To run the build from the package root:
# docker buildx build --platform linux/amd64,linux/arm64 -f ./docker-build/Dockerfile.steamos -t "breezy-kwin-steamos" .
# docker run --rm -t -v ./:/source -v --platform linux/amd64 "breezy-kwin-steamos:amd64"
# docker run --rm -t -v ./:/source -v --platform linux/arm64 "breezy-kwin-steamos:arm64"
FROM --platform=$TARGETPLATFORM ghcr.io/steamdeckhomebrew/holo-base:3.7@sha256:8da120a3e89c750abd0090c0aab86d543a55d667c3002c8d64960f7fd82ccdd6
ARG TARGETPLATFORM
ENV STEAMOS=1
RUN echo "SteamOS build - target platform: $TARGETPLATFORM"
RUN pacman -Sy --noconfirm --needed \
ca-certificates \
base-devel \
cmake \
pkgconf \
git \
curl \
wget \
extra-cmake-modules \
qt6-base \
qt6-declarative \
qt6-tools \
kconfig \
kconfigwidgets \
kcoreaddons \
kglobalaccel \
ki18n \
kcmutils \
kxmlgui \
kwindowsystem \
kwin \
&& pacman -Scc --noconfirm
WORKDIR /source
CMD bin/package_kwin_plugin

23
kwin/docker-build/init.sh Executable file
View File

@ -0,0 +1,23 @@
#!/bin/bash
# might be needed on a fresh docker setup:
# install qemu and qemu-user-static packages
# sudo docker context rm default
# docker run --privileged --rm tonistiigi/binfmt --install all
# sudo docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
# ls -l /proc/sys/fs/binfmt_misc/ # should contain qemu-<arch> 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 .

28
kwin/docker-build/run-build.sh Executable file
View File

@ -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/

61
kwin/src/CMakeLists.txt Normal file
View File

@ -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)

View File

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<kcfg xmlns="http://www.kde.org/standards/kcfg/1.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.kde.org/standards/kcfg/1.0
http://www.kde.org/standards/kcfg/1.0/kcfg.xsd" >
<kcfgfile name="kwinrc"/>
<group name="Effect-breezy_desktop">
<entry name="FocusedDisplayDistance" type="Int">
<default>85</default>
<min>25</min>
<max>225</max>
<label>Focused Display Distance</label>
</entry>
<entry name="AllDisplaysDistance" type="Int">
<default>105</default>
<min>25</min>
<max>225</max>
<label>All Displays Distance</label>
</entry>
<entry name="ZoomOnFocusEnabled" type="Bool">
<default>false</default>
<label>Zoom on Focus Enabled</label>
<description>Enable zooming in on the focused display.</description>
</entry>
<entry name="DisplaySpacing" type="Int">
<default>0</default>
<min>0</min>
<max>100</max>
<label>Display Spacing</label>
<description>How far apart the displays are visually (not logically)</description>
</entry>
</group>
</kcfg>

View File

@ -0,0 +1,5 @@
File=breezydesktopconfig.kcfg
ClassName=BreezyDesktopConfig
Singleton=true
Mutators=true
Notifiers=true

View File

@ -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 <kwin/main.h>
#include <core/outputbackend.h>
#include <functional>
#include <QAction>
#include <QBuffer>
#include <QFile>
#include <QFileSystemWatcher>
#include <QLoggingCategory>
#include <QQuickItem>
#include <QTimer>
#include <KGlobalAccel>
#include <KLocalizedString>
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<BreezyDesktopEffect>("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<void()> 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<QQuaternion> BreezyDesktopEffect::imuRotations() const {
return m_imuRotations;
}
quint32 BreezyDesktopEffect::imuTimeElapsedMs() const {
return m_imuTimeElapsedMs;
}
quint64 BreezyDesktopEffect::imuTimestamp() const {
return m_imuTimestamp;
}
QList<qreal> BreezyDesktopEffect::lookAheadConfig() const {
return m_lookAheadConfig;
}
QList<quint32> 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<uint8_t>(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<uint8_t>(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<uint8_t>(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<uint8_t>(data[DataView::VERSION[DataView::OFFSET_INDEX]]);
uint8_t enabledFlag = static_cast<uint8_t>(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<quint32>(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"

View File

@ -0,0 +1,124 @@
#pragma once
#include "kcm/shortcuts.h"
#include <effect/quickeffect.h>
#include <QAction>
#include <QFileSystemWatcher>
#include <QImage>
#include <QKeySequence>
#include <QQuaternion>
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<QQuaternion> 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<qreal> lookAheadConfig READ lookAheadConfig NOTIFY devicePropertiesChanged)
Q_PROPERTY(QList<quint32> 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<QQuaternion> imuRotations() const;
quint32 imuTimeElapsedMs() const;
quint64 imuTimestamp() const;
bool imuResetState() const;
QList<qreal> lookAheadConfig() const;
QList<quint32> 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<void()> triggeredFunc);
void recenter();
QTimer *m_shutdownTimer;
QString m_cursorImageSource;
bool m_enabled = false;
bool m_zoomOnFocusEnabled = false;
bool m_imuResetState;
QList<QQuaternion> m_imuRotations;
quint32 m_imuTimeElapsedMs;
quint64 m_imuTimestamp = 0;
QList<qreal> m_lookAheadConfig;
QList<quint32> 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<Output *> m_virtualOutputs;
};
} // namespace KWin

View File

@ -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
)

View File

@ -0,0 +1,151 @@
#include "shortcuts.h"
#include "breezydesktopeffectkcm.h"
#include "breezydesktopconfig.h"
#include "labeledslider.h"
#include "xrdriveripc.h"
#include <kwineffects_interface.h>
#include <KActionCollection>
#include <KGlobalAccel>
#include <KLocalizedString>
#include <KConfigWatcher>
#include <KSharedConfig>
#include <KPluginFactory>
#include <QAction>
#include <QFileDialog>
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"

View File

@ -0,0 +1,42 @@
#pragma once
#include <KCModule>
#include <KConfigWatcher>
#include <memory>
#include <QTimer>
#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
};

View File

@ -0,0 +1,159 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>BreezyDesktopEffectConfig</class>
<widget class="QWidget" name="BreezyDesktopEffectConfig">
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QLabel" name="labelDeviceConnectionStatus">
<property name="text">
<string>No device connected</string>
</property>
<property name="alignment">
<set>Qt::AlignHCenter|Qt::AlignVCenter</set>
</property>
<property name="font">
<font>
<pointsize>14</pointsize>
<weight>75</weight>
<bold>true</bold>
</font>
</property>
</widget>
</item>
<item>
<widget class="QTabWidget" name="tabWidget">
<property name="tabPosition">
<enum>QTabWidget::North</enum>
</property>
<property name="tabShape">
<enum>QTabWidget::Rounded</enum>
</property>
<widget class="QWidget" name="tabGeneral">
<attribute name="title">
<string>&amp;General</string>
</attribute>
<layout class="QFormLayout" name="formLayout">
<item row="0" column="0">
<widget class="QCheckBox" name="kcfg_ZoomOnFocusEnabled">
<property name="text">
<string>Zoom on Focus</string>
</property>
<property name="checked">
<bool>false</bool>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="labelFocusedDisplayDistance">
<property name="text">
<string>Focused Display Distance:</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="LabeledSlider" name="kcfg_FocusedDisplayDistance">
<property name="decimalShift">
<double>2</double>
</property>
<property name="tickPosition">
<enum>QSlider::TicksBelow</enum>
</property>
<property name="tickInterval">
<double>25</double>
</property>
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="tracking">
<bool>true</bool>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="labelAllDisplaysDistance">
<property name="text">
<string>All Displays Distance:</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="LabeledSlider" name="kcfg_AllDisplaysDistance">
<property name="decimalShift">
<double>2</double>
</property>
<property name="tickPosition">
<enum>QSlider::TicksBelow</enum>
</property>
<property name="tickInterval">
<double>25</double>
</property>
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="tracking">
<bool>true</bool>
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QLabel" name="labelDisplaySpacing">
<property name="text">
<string>Display Spacing:</string>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="QSlider" name="kcfg_DisplaySpacing">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="tracking">
<bool>true</bool>
</property>
</widget>
</item>
<item row="4" column="0" colspan="2">
<widget class="KShortcutsEditor" name="shortcutsEditor" native="true">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
</widget>
</item>
</layout>
</widget>
<widget class="QWidget" name="tabAdvanced">
<attribute name="title">
<string>&amp;Advanced</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayoutAdvanced">
<item>
<widget class="QLabel" name="labelAdvancedPlaceholder">
<property name="text">
<string>Advanced settings will appear here.</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
</layout>
</widget>
</widget>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>KShortcutsEditor</class>
<extends>QWidget</extends>
<header>kshortcutseditor.h</header>
<container>1</container>
</customwidget>
</customwidgets>
<resources/>
<connections>
</connections>
</ui>

View File

@ -0,0 +1 @@
#include "labeledslider.h"

View File

@ -0,0 +1,157 @@
#pragma once
#include <QSlider>
#include <QPainter>
#include <QPainterPath>
#include <QStyleOptionSlider>
#include <algorithm> // 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;
}
};

31
kwin/src/kcm/shortcuts.h Normal file
View File

@ -0,0 +1,31 @@
#pragma once
#include <QKeySequence>
#include <Qt>
#include <QString>
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")
};
}

10
kwin/src/main.cpp Normal file
View File

@ -0,0 +1,10 @@
#include "breezydesktopeffect.h"
namespace KWin
{
KWIN_EFFECT_FACTORY(BreezyDesktopEffect, "metadata.json")
} // namespace KWin
#include "main.moc"

17
kwin/src/metadata.json Normal file
View File

@ -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"
}

View File

@ -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
}
}
}

View File

@ -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
}
}
}
]
}

View File

@ -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)
));
}
}
}
}

View File

@ -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
}
}

400
kwin/src/qml/Displays.qml Normal file
View File

@ -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;
}
}

122
kwin/src/qml/main.qml Normal file
View File

@ -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;
}
}

View File

@ -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
$<$<CXX_COMPILER_ID:MSVC>:/EHsc>
$<$<NOT:$<CXX_COMPILER_ID:MSVC>>:-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)

View File

@ -0,0 +1,142 @@
// New implementation using QProcess to call python
#include "xrdriveripc.h"
#include <iostream>
#include <cmath>
#include <QFileInfo>
#include <QProcess>
#include <QProcessEnvironment>
#include <QStandardPaths>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonValue>
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<std::string>(it->second)) {
return std::get<std::string>(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<XRDict> 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<XRDict> 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<bool>(v)) obj.insert(QString::fromStdString(k), std::get<bool>(v));
else if (std::holds_alternative<int>(v)) obj.insert(QString::fromStdString(k), std::get<int>(v));
else if (std::holds_alternative<double>(v)) obj.insert(QString::fromStdString(k), std::get<double>(v));
else if (std::holds_alternative<std::string>(v)) obj.insert(QString::fromStdString(k), QString::fromStdString(std::get<std::string>(v)));
}
QByteArray payload = QJsonDocument(obj).toJson(QJsonDocument::Compact);
QByteArray out = invokePython(QStringLiteral("write_config"), payload, {});
return !out.isEmpty();
}
bool XRDriverIPC::writeControlFlags(const std::map<std::string, bool> &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;
}

View File

@ -0,0 +1,111 @@
// C++ bridge now invoking xrdriveripc via external python process
#pragma once
#include <string>
#include <map>
#include <variant>
#include <vector>
#include <optional>
#include <QString>
#include <QByteArray>
// 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<std::monostate, bool, int, double, std::string>;
using XRDict = std::map<std::string, XRValue>;
class XR_DRIVER_IPC_EXPORT XRDriverIPC {
public:
static XRDriverIPC &instance();
static std::string string(const XRDict &dict, const std::string &key);
std::optional<XRDict> retrieveConfig();
std::optional<XRDict> retrieveDriverState();
bool writeConfig(const XRDict &configUpdate);
bool writeControlFlags(const std::map<std::string, bool> &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
};

View File

@ -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())