diff --git a/.gitignore b/.gitignore index d50ac39..c501b9e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,9 @@ gschemas.compiled out/ *.po~ gnome-44-max/ +gnome-45/ +kwin/src/xrdriveripc/xrdriveripc.py +kwin/VERSION +kwin/build-test/ +kwin/src/qml/calibrating.png +kwin/src/qml/custom_banner.png diff --git a/VERSION b/VERSION index e164972..ed2af5c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.0.18 \ No newline at end of file +2.0.19 diff --git a/bin/breezy_gnome_setup b/bin/breezy_gnome_setup index fc0c3a0..3170477 100755 --- a/bin/breezy_gnome_setup +++ b/bin/breezy_gnome_setup @@ -54,7 +54,7 @@ if [ -z "$binary_path_arg" ] then # download and unzip the binary echo "Downloading to: ${tmp_dir}/$FILE_NAME" - curl -L -O $binary_download_url + curl -L "$binary_download_url" > "$FILE_NAME" else FILE_NAME=$(basename $binary_path_arg) if [[ "$binary_path_arg" = /* ]]; then diff --git a/bin/breezy_kwin_setup b/bin/breezy_kwin_setup new file mode 100755 index 0000000..e4c9c1b --- /dev/null +++ b/bin/breezy_kwin_setup @@ -0,0 +1,156 @@ +#!/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" + +print_missing_dependencies() { + echo "" + printf "\n\033[1;31mMissing required components\033[0m\n" + echo "" + echo "Install the corresponding packages with your package manager, then rerun this setup:" + echo " Debian/Ubuntu: sudo apt-get update && sudo apt-get install -y extra-cmake-modules kwin-dev libkf6config-dev libkf6configwidgets-dev libkf6coreaddons-dev libkf6kcmutils-dev libkf6globalaccel-dev libkf6i18n-dev libkf6windowsystem-dev libkf6xmlgui-dev qt6-base-dev qt6-declarative-dev libdrm-dev" + echo " Fedora/RHEL: sudo dnf install -y extra-cmake-modules kwin-devel kf6-kconfig-devel kf6-kconfigwidgets-devel kf6-kcoreaddons-devel kf6-kcmutils-devel kf6-kglobalaccel-devel kf6-ki18n-devel kf6-kwindowsystem-devel kf6-kxmlgui-devel qt6-qtbase-devel qt6-qttools-devel wayland-devel libepoxy-devel" + echo " Arch: sudo pacman -S --needed extra-cmake-modules qt6-base qt6-declarative qt6-tools kconfig kconfigwidgets kcoreaddons kglobalaccel ki18n kcmutils kxmlgui kwindowsystem kwin" + echo "" + printf "\n\033[1;33mIf you continue to see this issue\033[0m rerun as follows and attach the full output to a bug report:\n" + printf "\n\tPRINT_FULL_STDERR=1 %s\n" "$0 $*" + exit 1 +} + +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 + http_code=$(curl -s -o /dev/null -w "%{http_code}" -L "$binary_download_url" || echo "000") + + if [[ "$http_code" =~ ^(2|3) ]]; then + echo "Downloading to: ${tmp_dir}/$FILE_NAME" + curl -L "$binary_download_url" -o "$FILE_NAME" + else + check_command "git" + check_command "cmake" + check_command "make" + + # handle -v / --tag like the rest of the script + if [ "$1" = "--tag" ] && [ -n "$2" ]; then + requested_tag="$2" + fi + + # resolve tag: prefer requested_tag, then LATEST_RELEASE if already fetched, else query GitHub API + if [ -n "$requested_tag" ]; then + tag="$requested_tag" + else + tag=$(curl -s "https://api.github.com/repos/wheaney/breezy-desktop/releases/latest" \ + | grep -m1 '"tag_name":' \ + | sed -E 's/.*"tag_name":[[:space:]]*"([^"]+)".*/\1/') + fi + + if [ -z "$tag" ]; then + echo "Failed to determine latest tag for wheaney/breezy-desktop" 1>&2 + exit 1 + fi + + if [ -z "${DEV_BUILD+x}" ]; then + echo "Cloning wheaney/breezy-desktop (tag: ${tag})" + if git clone --depth 1 --branch "$tag" "https://github.com/wheaney/breezy-desktop.git" breezy-desktop 2>/dev/null; then + pushd breezy-desktop > /dev/null + else + git clone "https://github.com/wheaney/breezy-desktop.git" breezy-desktop + pushd breezy-desktop > /dev/null + git checkout "$tag" + fi + + echo "Downloading git submodules" + git submodule sync --recursive || true + git submodule update --init modules/XRLinuxDriver + git submodule update --init modules/sombrero + git submodule update --init ui/modules/PyXRLinuxDriverIPC + else + pushd $start_dir > /dev/null + fi + + echo "Building Breezy Desktop from source, this may take a while..." + set +e + pkgkwin_stderr=$(LOCAL_BUILD_SYSTEM=1 bin/package_kwin --download-driver 2>&1) + pkgkwin_rc=$? + set -e + if [ "$pkgkwin_rc" -ne 0 ]; then + if echo "$pkgkwin_stderr" | grep -qi "could not find" && [ -z "${PRINT_FULL_STDERR+x}" ]; then + print_missing_dependencies "$*" + else + echo "$pkgkwin_stderr" + fi + echo "Error: Breezy Desktop build failed with exit code $pkgkwin_rc" + exit $pkgkwin_rc + fi + FILE_NAME="breezyKWin-$ARCH.tar.gz" + cp "out/$FILE_NAME" "$tmp_dir" + popd > /dev/null + fi +else + FILE_NAME=$(basename $binary_path_arg) + if [[ "$binary_path_arg" = /* ]]; then + abs_path="$binary_path_arg" + else + # Convert relative path to absolute path + abs_path=$(realpath "$start_dir/$binary_path_arg") + fi + cp $abs_path $tmp_dir +fi + +echo "Extracting to: ${tmp_dir}/breezy_kwin" +tar -xf $FILE_NAME + +pushd breezy_kwin > /dev/null + +# run the setup script that comes with this release +bin/setup $metrics_version + +echo "Deleting temp directory: ${tmp_dir}" +rm -rf $tmp_dir +cd "$(dirs -l -0)" && dirs -c diff --git a/bin/breezy_vulkan_setup b/bin/breezy_vulkan_setup index 464e785..bac0377 100755 --- a/bin/breezy_vulkan_setup +++ b/bin/breezy_vulkan_setup @@ -39,10 +39,11 @@ fi if [ -z "$binary_path_arg" ] then - # download and unzip the latest driver - echo "Downloading to: ${tmp_dir}/breezyVulkan-$ARCH.tar.gz" - curl -L -O $binary_download_url + # download and unzip the binary binary_path_arg="breezyVulkan-$ARCH.tar.gz" + echo "Downloading to: ${tmp_dir}/$binary_path_arg" + + curl -L "$binary_download_url" > "$binary_path_arg" else if [[ "$binary_path_arg" = /* ]]; then abs_path="$binary_path_arg" diff --git a/bin/package_kwin b/bin/package_kwin new file mode 100755 index 0000000..6ba691f --- /dev/null +++ b/bin/package_kwin @@ -0,0 +1,100 @@ +#!/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 [ "$1" == "--download-driver" ]; then + driver_download_url="https://github.com/wheaney/XRLinuxDriver/releases/latest/download/xrDriver-$ARCH.tar.gz" + setup_download_url="https://github.com/wheaney/XRLinuxDriver/releases/latest/download/xr_driver_setup" + + curl -L "$driver_download_url" > "$PACKAGE_DIR/xrDriver.tar.gz" + curl -L "$setup_download_url" > "$PACKAGE_DIR/bin/xr_driver_setup" + chmod +x "$PACKAGE_DIR/bin/xr_driver_setup" + + echo "Downloaded XRLinuxDriver binary and setup script, with hashes:" + printf '\txrDriver-%s.tar.gz: %s\n' "$ARCH" "$(sha256sum "$PACKAGE_DIR/xrDriver.tar.gz" | sort | sha256sum | sed 's/ .*//')" + printf '\txr_driver_setup: %s\n' "$(sha256sum "$PACKAGE_DIR/bin/xr_driver_setup" | sort | sha256sum | sed 's/ .*//')" +else + 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 > /dev/null + + # 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 > /dev/null + fi + + XR_DRIVER_TMP_DIR=$(mktemp -d -t xr-driver-XXXXXXXXXX) + pushd $XR_DRIVER_TMP_DIR > /dev/null + 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 > /dev/null + 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/xr_driver_setup +fi + +# 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 +cp VERSION $KWIN_DIR +cp modules/sombrero/*.png $KWIN_DIR/src/qml + +pushd $KWIN_DIR > /dev/null +if [ -z "${LOCAL_BUILD_SYSTEM+x}" ]; then + docker-build/init.sh + docker-build/run-build.sh "$BUILD_ARCH" +else + bin/package_kwin_plugin +fi +popd > /dev/null +cp $KWIN_DIR/out/breezyKWinPlugin-$BUILD_ARCH.tar.gz $PACKAGE_DIR/breezyKWinPlugin.tar.gz + +pushd $TMP_DIR > /dev/null +tar -zcvf $BUILD_FILE_NAME breezy_kwin +popd > /dev/null + +mkdir -p out +if [ -e "out/$BUILD_FILE_NAME" ]; then + rm out/$BUILD_FILE_NAME +fi +cp $TMP_DIR/$BUILD_FILE_NAME out + +rm -rf $TMP_DIR \ No newline at end of file diff --git a/gnome/bin/setup b/gnome/bin/setup index db438da..e61c132 100755 --- a/gnome/bin/setup +++ b/gnome/bin/setup @@ -37,7 +37,19 @@ if ! { python3 -c "$PYTHON_GI_CHECK" 2>/dev/null || python -c "$PYTHON_GI_CHECK" fi fi -if ! gst-inspect-1.0 pipewiresrc &>/dev/null; then +gst_pipewire_found=0 +if command -v dpkg >/dev/null 2>&1; then + # Debian/Ubuntu + gst_pipewire_found=$(dpkg -l | grep -c "gstreamer1.0-pipewire") +elif command -v pacman >/dev/null 2>&1; then + # Arch Linux + gst_pipewire_found=$(pacman -Qq gst-plugin-pipewire 2>/dev/null | wc -l) +elif command -v rpm >/dev/null 2>&1; then + # RPM-based (Fedora, RHEL, openSUSE, etc.) + gst_pipewire_found=$((rpm -q pipewire-gstreamer &>/dev/null && echo "found") | wc -l) +fi + +if [ "$gst_pipewire_found" -eq 0 ]; then if [ -z "$BREEZY_IGNORE_GST_ERRORS" ]; then printf "\033[1;31mERROR:\033[0m Pipewire GStreamer plugin not found\n" printf "Please install the required Pipewire GStreamer plugin:\n" diff --git a/gnome/src/math.js b/gnome/src/math.js index f1c9983..40356ed 100644 --- a/gnome/src/math.js +++ b/gnome/src/math.js @@ -14,7 +14,7 @@ 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) } } \ No newline at end of file diff --git a/gnome/src/monitormanager.js b/gnome/src/monitormanager.js index 4c57171..4c02ce6 100644 --- a/gnome/src/monitormanager.js +++ b/gnome/src/monitormanager.js @@ -34,6 +34,7 @@ export const SUPPORTED_MONITOR_PRODUCTS = [ 'Air 2 Ultra', 'SmartGlasses', // TCL/RayNeo 'Rokid Max', + 'Rokid Max 2', 'Rokid Air', NESTED_MONITOR_PRODUCT ]; diff --git a/kwin/CMakeLists.txt b/kwin/CMakeLists.txt new file mode 100644 index 0000000..c3e8411 --- /dev/null +++ b/kwin/CMakeLists.txt @@ -0,0 +1,55 @@ +cmake_minimum_required(VERSION 3.20) + +project(breezy_desktop VERSION 0.0.1 LANGUAGES CXX) + +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +include(cmake/default-vars.cmake) + +find_package(ECM "5.100" REQUIRED NO_MODULE) +set(CMAKE_MODULE_PATH + ${CMAKE_MODULE_PATH} + ${ECM_MODULE_PATH} + ${ECM_KDE_MODULE_DIR} +) +include(cmake/qtversion.cmake) +include(FeatureSummary) +include(KDEInstallDirs) +include(KDECMakeSettings) +include(KDECompilerSettings NO_POLICY_SCOPE) + +include(cmake/qtversion.cmake) + +# required frameworks by Core +find_package(KF${QT_MAJOR_VERSION} ${KF_MIN_VERSION} REQUIRED COMPONENTS + Config + ConfigWidgets + CoreAddons + GlobalAccel + I18n + KCMUtils + WindowSystem + XmlGui +) + +if(${QT_MAJOR_VERSION} EQUAL 6) + find_package(KF${QT_MAJOR_VERSION} ${KF_MIN_VERSION} REQUIRED COMPONENTS KCMUtils) + find_package(KWin REQUIRED COMPONENTS kwineffects) + message(STATUS "Found KWin Version: ${KWin_VERSION}") +endif () +include(cmake/info.cmake) +find_package(epoxy REQUIRED) +find_package(XCB REQUIRED COMPONENTS XCB) +find_package(KWinDBusInterface CONFIG REQUIRED) + +add_subdirectory(src) +ki18n_install(po) + +feature_summary(WHAT ALL FATAL_ON_MISSING_REQUIRED_PACKAGES) + +include(cmake/test.cmake) +include(cmake/pack.cmake) diff --git a/kwin/bin/breezy_kwin_uninstall b/kwin/bin/breezy_kwin_uninstall new file mode 100755 index 0000000..8f620b2 --- /dev/null +++ b/kwin/bin/breezy_kwin_uninstall @@ -0,0 +1,74 @@ +#!/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 + +if command -v qdbus >/dev/null 2>&1; then + QDBUS_CMD=$(command -v qdbus) +elif command -v qdbus6 >/dev/null 2>&1; then + QDBUS_CMD=$(command -v qdbus6) +fi + +if [ -n "$QDBUS_CMD" ]; then + if $QDBUS_CMD org.kde.KWin /Effects org.kde.kwin.Effects.unloadEffect breezy_desktop; then + [ "$for_install" -eq 0 ] && echo "Unloaded breezy_desktop effect" + fi +fi + +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" +BREEZY_LIBRARY_DIR="$LIB_HOME/breezy_kwin" + +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 [[ -d "$BREEZY_LIBRARY_DIR" ]]; then + [ "$for_install" -eq 0 ] && echo "Removing $BREEZY_LIBRARY_DIR and its contents" + $SUDO rm -rf "$BREEZY_LIBRARY_DIR" +fi + +if [[ -e "$XDG_BIN_HOME/xr_driver_uninstall" && "$for_install" -eq 0 ]]; then + echo "Uninstalling XRLinuxDriver" + sudo "$XDG_BIN_HOME/xr_driver_uninstall" +fi + +# this script is self-deleting, leave this as the last command +rm -f $XDG_BIN_HOME/breezy_kwin_uninstall \ No newline at end of file diff --git a/kwin/bin/package_kwin_plugin b/kwin/bin/package_kwin_plugin new file mode 100755 index 0000000..a60efe5 --- /dev/null +++ b/kwin/bin/package_kwin_plugin @@ -0,0 +1,32 @@ +#!/usr/bin/env bash + +# exit when any command fails +set -e + +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 "cmake" +check_command "make" + +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 > /dev/null +cmake .. +make +cpack -G TGZ +popd > /dev/null + +mkdir -p out +cp $BUILD_PATH/breezy_desktop.tar.gz out/breezyKWinPlugin-$ARCH.tar.gz \ No newline at end of file diff --git a/kwin/bin/setup b/kwin/bin/setup new file mode 100755 index 0000000..28a63f1 --- /dev/null +++ b/kwin/bin/setup @@ -0,0 +1,104 @@ +#!/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:\$QT_PLUGIN_PATH\"" +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" <> "$PLASMA_ENV_SCRIPT" < files + +if [[ "$1" == "--init" || ! $(docker buildx inspect breezykwinbuilder &>/dev/null; echo $?) -eq 0 ]]; then + # start fresh + echo "Creating new docker builder instance" + docker buildx rm breezykwinbuilder 2>/dev/null || true + docker buildx create --use --name breezykwinbuilder --driver docker-container --driver-opt image=moby/buildkit:latest +else + echo "Using existing docker builder instance" + docker buildx use breezykwinbuilder +fi + +echo "Building docker image" +docker buildx build --platform linux/amd64 -f ./docker-build/Dockerfile -t "breezy-kwin:amd64" --load . +# docker buildx build --platform linux/arm64 -f ./docker-build/Dockerfile -t "breezy-kwin:arm64" --load . +docker buildx build --platform linux/amd64 -f ./docker-build/Dockerfile.steamos -t "breezy-kwin-steamos:amd64" --load . \ No newline at end of file diff --git a/kwin/docker-build/run-build.sh b/kwin/docker-build/run-build.sh new file mode 100755 index 0000000..ff19c84 --- /dev/null +++ b/kwin/docker-build/run-build.sh @@ -0,0 +1,28 @@ +#!/bin/bash + +set -e + +USER=${SUDO_USER:-$USER} +GROUP=$(id -gn $USER) + +# Run containers for each architecture +if [[ "$1" == "x86_64" || -z "$1" ]]; then + sudo rm -rf build/ + docker run --rm -t -v ./:/source --platform linux/amd64 "breezy-kwin:amd64" + sudo chown -R $USER:$GROUP out/ +fi + +if [[ "$1" == "aarch64" || -z "$1" ]]; then + sudo rm -rf build/ + docker run --rm -t -v ./:/source --platform linux/arm64 "breezy-kwin:arm64" + sudo chown -R $USER:$GROUP out/ +fi + +if [[ "$1" == "steamos" || -z "$1" ]]; then + sudo rm -rf build/ + docker run --rm -t -v ./:/source --platform linux/amd64 "breezy-kwin-steamos:amd64" + sudo chown -R $USER:$GROUP out/ +fi + +# build directory structure is all owned by root because of docker, delete it all now +sudo chown -R $USER:$GROUP build/ \ No newline at end of file diff --git a/kwin/src/CMakeLists.txt b/kwin/src/CMakeLists.txt new file mode 100644 index 0000000..5b88fdd --- /dev/null +++ b/kwin/src/CMakeLists.txt @@ -0,0 +1,70 @@ +add_subdirectory(xrdriveripc) + +file(READ "${CMAKE_CURRENT_SOURCE_DIR}/../VERSION" BREEZY_DESKTOP_VERSION_RAW) +if(NOT BREEZY_DESKTOP_VERSION_RAW) + set(BREEZY_DESKTOP_VERSION_RAW "dev") +endif() +string(STRIP "${BREEZY_DESKTOP_VERSION_RAW}" BREEZY_DESKTOP_VERSION) + +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} + BREEZY_DESKTOP_VERSION_STR=\"${BREEZY_DESKTOP_VERSION}\" +) +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 + Qt6::DBus + + KF6::ConfigCore + KF6::ConfigGui + KF6::CoreAddons + KF6::GlobalAccel + KF6::I18n + KF6::WindowSystem + + KWin::kwin + + xr_driver_ipc +) + + +install(DIRECTORY qml DESTINATION ${KDE_INSTALL_DATADIR}/kwin/effects/breezy_desktop) \ No newline at end of file diff --git a/kwin/src/breezydesktopconfig.kcfg b/kwin/src/breezydesktopconfig.kcfg new file mode 100644 index 0000000..2a8ec8b --- /dev/null +++ b/kwin/src/breezydesktopconfig.kcfg @@ -0,0 +1,66 @@ + + + + + + 85 + 25 + 225 + + + + 105 + 25 + 225 + + + + false + + Enable zooming in on the focused display. + + + 0 + 0 + 100 + + How far apart the displays are visually (not logically) + + + 0 + -250 + 250 + + Horizontal offset as a percent of the viewport width (-2.50 to 2.50) + + + 0 + -250 + 250 + + Vertical offset as a percent of the viewport height (-2.50 to 2.50) + + + 0 + 0 + 3 + + How to arrange monitors: 0=Auto, 1=Horizontal, 2=Vertical, 3=Flat + + + 3 + 0 + 3 + + 0=None, 1=Medium, 2=High, 3=Very High + + + true + + Whether to remove any virtual displays when the effect is disabled + + + diff --git a/kwin/src/breezydesktopconfig.kcfgc b/kwin/src/breezydesktopconfig.kcfgc new file mode 100644 index 0000000..f1666fe --- /dev/null +++ b/kwin/src/breezydesktopconfig.kcfgc @@ -0,0 +1,5 @@ +File=breezydesktopconfig.kcfg +ClassName=BreezyDesktopConfig +Singleton=true +Mutators=true +Notifiers=true \ No newline at end of file diff --git a/kwin/src/breezydesktopeffect.cpp b/kwin/src/breezydesktopeffect.cpp new file mode 100644 index 0000000..e27830d --- /dev/null +++ b/kwin/src/breezydesktopeffect.cpp @@ -0,0 +1,628 @@ + +#include "core/rendertarget.h" +#include "core/renderviewport.h" +#include "kcm/shortcuts.h" +#include "breezydesktopeffect.h" +#include "breezydesktopconfig.h" +#include "effect/effect.h" +#include "effect/effecthandler.h" +#include "opengl/glutils.h" +#include "xrdriveripc.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +Q_LOGGING_CATEGORY(KWIN_XR, "kwin.xr") + +// A small DBus adaptor to expose effect controls to the KCM. +// Service is provided by KWin (org.kde.KWin). We only register an object path. +// Interface: com.xronlinux.BreezyDesktop, Path: /com/xronlinux/BreezyDesktop +namespace { +class BreezyDesktopDBusAdaptor : public QObject { + Q_OBJECT + Q_CLASSINFO("D-Bus Interface", "com.xronlinux.BreezyDesktop") +public: + explicit BreezyDesktopDBusAdaptor(KWin::BreezyDesktopEffect *effect) + : QObject(effect), m_effect(effect) {} + +public Q_SLOTS: + void AddVirtualDisplay(int width, int height) { + QMetaObject::invokeMethod(m_effect, [this, width, height]() { + m_effect->addVirtualDisplay(QSize(width, height)); + }, Qt::QueuedConnection); + } + +private: + KWin::BreezyDesktopEffect *m_effect; +}; +} // namespace + +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() +{ + qCCritical(KWIN_XR) << "\t\t\tBreezy - constructor"; + qmlRegisterUncreatableType("org.kde.kwin.effect.breezy_desktop", 1, 0, "BreezyDesktopEffect", QStringLiteral("BreezyDesktop cannot be created in QML")); + + 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(); + + // Register DBus object under KWin's session bus name + auto *adaptor = new BreezyDesktopDBusAdaptor(this); + const bool dbusOk = QDBusConnection::sessionBus().registerObject( + QStringLiteral("/com/xronlinux/BreezyDesktop"), + adaptor, + QDBusConnection::ExportAllSlots); + if (!dbusOk) { + qCWarning(KWIN_XR) << "Failed to register DBus object /com/xronlinux/BreezyDesktop"; + } +} + +BreezyDesktopEffect::~BreezyDesktopEffect() +{ + qCCritical(KWIN_XR) << "\t\t\tBreezy - destructor"; + if (m_shmFileWatcher) { + if (!DataView::SHM_PATH.isEmpty()) { + m_shmFileWatcher->removePath(DataView::SHM_PATH); + } + m_shmFileWatcher->deleteLater(); + m_shmFileWatcher = nullptr; + } + if (m_shmDirectoryWatcher) { + m_shmDirectoryWatcher->deleteLater(); + m_shmDirectoryWatcher = nullptr; + } + deactivate(); +} + +void BreezyDesktopEffect::setupGlobalShortcut(const BreezyShortcuts::Shortcut &shortcut, std::function triggeredFunc) { + QAction *action = new QAction(this); + action->setObjectName(shortcut.actionName); + action->setText(shortcut.actionText); + KGlobalAccel::self()->setDefaultShortcut(action, {shortcut.shortcut}); + KGlobalAccel::self()->setShortcut(action, {shortcut.shortcut}); + connect(action, &QAction::triggered, this, triggeredFunc); +} + +void BreezyDesktopEffect::recenter() { + XRDriverIPC::instance().writeControlFlags({ + {"recenter_screen", true} + }); +} + +void BreezyDesktopEffect::reconfigure(ReconfigureFlags) +{ + BreezyDesktopConfig::self()->read(); + setFocusedDisplayDistance(BreezyDesktopConfig::focusedDisplayDistance() / 100.0f); + setAllDisplaysDistance(BreezyDesktopConfig::allDisplaysDistance() / 100.0f); + setDisplaySpacing(BreezyDesktopConfig::displaySpacing() / 1000.0f); + setZoomOnFocusEnabled(BreezyDesktopConfig::zoomOnFocusEnabled()); + qreal horiz = BreezyDesktopConfig::displayHorizontalOffset() / 100.0f; + qreal vert = BreezyDesktopConfig::displayVerticalOffset() / 100.0f; + int wrap = BreezyDesktopConfig::displayWrappingScheme(); + int aaQuality = BreezyDesktopConfig::antialiasingQuality(); + bool removeVD = BreezyDesktopConfig::removeVirtualDisplaysOnDisable(); + bool changed = false; + if (!qFuzzyCompare(m_displayHorizontalOffset, horiz)) { m_displayHorizontalOffset = horiz; changed = true; } + if (!qFuzzyCompare(m_displayVerticalOffset, vert)) { m_displayVerticalOffset = vert; changed = true; } + if (m_displayWrappingScheme != wrap) { m_displayWrappingScheme = wrap; Q_EMIT displayWrappingSchemeChanged(); } + if (m_antialiasingQuality != aaQuality) { m_antialiasingQuality = aaQuality; Q_EMIT antialiasingQualityChanged(); } + if (m_removeVirtualDisplaysOnDisable != removeVD) { m_removeVirtualDisplaysOnDisable = removeVD; Q_EMIT removeVirtualDisplaysOnDisableChanged(); } + if (changed) Q_EMIT displayOffsetChanged(); +} + +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 - disabling"; + disableDriver(); + } else { + qCCritical(KWIN_XR) << "\t\t\tBreezy - toggle - enabling"; + enableDriver(); + } +} + +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(); +} + +void BreezyDesktopEffect::deactivate() +{ + qCCritical(KWIN_XR) << "\t\t\tBreezy - deactivate"; + disconnect(effects, &EffectsHandler::cursorShapeChanged, this, &BreezyDesktopEffect::updateCursorImage); + m_cursorUpdateTimer->stop(); + showCursor(); + + if (m_removeVirtualDisplaysOnDisable) { + for (auto output : m_virtualOutputs) { + KWin::kwinApp()->outputBackend()->removeVirtualOutput(output); + } + m_virtualOutputs.clear(); + } + + setRunning(false); +} + +void BreezyDesktopEffect::enableDriver() +{ + qCCritical(KWIN_XR) << "\t\t\tBreezy - enableDriver"; + QJsonObject newConfig = QJsonObject(); + auto configJsonOpt = XRDriverIPC::instance().retrieveConfig(); + if (configJsonOpt) { + newConfig = configJsonOpt.value(); + } + newConfig.insert(QStringLiteral("disabled"), false); + newConfig.insert(QStringLiteral("output_mode"), QStringLiteral("external_only")); + newConfig.insert(QStringLiteral("external_mode"), QStringLiteral("breezy_desktop")); + XRDriverIPC::instance().writeConfig(newConfig); +} + +void BreezyDesktopEffect::disableDriver() +{ + qCCritical(KWIN_XR) << "\t\t\tBreezy - disableDriver"; + QJsonObject newConfig = QJsonObject(); + auto configJsonOpt = XRDriverIPC::instance().retrieveConfig(); + if (configJsonOpt) { + newConfig = configJsonOpt.value(); + } + newConfig.insert(QStringLiteral("external_mode"), QStringLiteral("none")); + XRDriverIPC::instance().writeConfig(newConfig); +} + +void BreezyDesktopEffect::addVirtualDisplay(QSize 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; + if (m_zoomOnFocusEnabled && m_focusedDisplayDistance > m_allDisplaysDistance) { + setFocusedDisplayDistance(m_allDisplaysDistance); + BreezyDesktopConfig::setFocusedDisplayDistance(static_cast(m_focusedDisplayDistance * 100.0f)); + } + BreezyDesktopConfig::setZoomOnFocusEnabled(enabled); + BreezyDesktopConfig::self()->save(); + Q_EMIT zoomOnFocusChanged(); + } +} + +bool BreezyDesktopEffect::imuResetState() const { + return m_imuResetState; +} + +QList BreezyDesktopEffect::imuRotations() const { + return m_imuRotations; +} + +quint32 BreezyDesktopEffect::imuTimeElapsedMs() const { + return m_imuTimeElapsedMs; +} + +quint64 BreezyDesktopEffect::imuTimestamp() const { + return m_imuTimestamp; +} + +QList BreezyDesktopEffect::lookAheadConfig() const { + return m_lookAheadConfig; +} + +QList BreezyDesktopEffect::displayResolution() const { + return m_displayResolution; +} + +qreal BreezyDesktopEffect::focusedDisplayDistance() const { + return m_focusedDisplayDistance; +} + +void BreezyDesktopEffect::setFocusedDisplayDistance(qreal distance) { + if (distance != m_focusedDisplayDistance) { + m_focusedDisplayDistance = std::clamp(distance, 0.2, m_allDisplaysDistance); + Q_EMIT focusedDisplayDistanceChanged(); + } +} + +qreal BreezyDesktopEffect::allDisplaysDistance() const { + return m_allDisplaysDistance; +} + +void BreezyDesktopEffect::setAllDisplaysDistance(qreal distance) { + if (distance != m_allDisplaysDistance) { + qreal min = m_zoomOnFocusEnabled ? m_focusedDisplayDistance : 0.2; + m_allDisplaysDistance = std::clamp(distance, min, 2.5); + Q_EMIT allDisplaysDistanceChanged(); + } +} + +qreal BreezyDesktopEffect::displaySpacing() const { + return m_displaySpacing; +} + +void BreezyDesktopEffect::setDisplaySpacing(qreal spacing) { + if (spacing != m_displaySpacing) { + m_displaySpacing = spacing; + Q_EMIT displaySpacingChanged(); + } +} + +qreal BreezyDesktopEffect::displayHorizontalOffset() const { + return m_displayHorizontalOffset; +} + +qreal BreezyDesktopEffect::displayVerticalOffset() const { + return m_displayVerticalOffset; +} + +int BreezyDesktopEffect::displayWrappingScheme() const { + return m_displayWrappingScheme; +} + +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; +} + +int BreezyDesktopEffect::antialiasingQuality() const { + return m_antialiasingQuality; +} + +bool BreezyDesktopEffect::removeVirtualDisplaysOnDisable() const { + return m_removeVirtualDisplaysOnDisable; +} + +bool BreezyDesktopEffect::checkParityByte(const char* data) { + const uint8_t parityByte = static_cast(data[DataView::IMU_PARITY_BYTE[DataView::OFFSET_INDEX]]); + uint8_t parity = 0; + + const int dateBytes = DataView::IMU_DATE_MS[DataView::COUNT_INDEX] * DataView::IMU_DATE_MS[DataView::SIZE_INDEX]; + for (int i = 0; i < dateBytes; ++i) { + parity ^= static_cast(data[DataView::IMU_DATE_MS[DataView::OFFSET_INDEX] + i]); + } + + const int quatBytes = DataView::IMU_QUAT_DATA[DataView::COUNT_INDEX] * DataView::IMU_QUAT_DATA[DataView::SIZE_INDEX]; + for (int i = 0; i < quatBytes; ++i) { + parity ^= static_cast(data[DataView::IMU_QUAT_DATA[DataView::OFFSET_INDEX] + i]); + } + + return parityByte == parity; +} + +// TODO - can this be something callable from the camera qml code, so it's pulled only when needed? +static qint64 lastConfigUpdate = 0; +static qint64 activatedAt = 0; +void BreezyDesktopEffect::updateImuRotation() { + const QString shmPath = QStringLiteral("/dev/shm/breezy_desktop_imu"); + QFile shmFile(shmPath); + if (!shmFile.open(QIODevice::ReadOnly)) { + return; + } + QByteArray buffer = shmFile.readAll(); + shmFile.close(); + if (buffer.size() != DataView::LENGTH) return; + + const char* data = buffer.constData(); + if (!checkParityByte(data)) return; + + uint8_t version = static_cast(data[DataView::VERSION[DataView::OFFSET_INDEX]]); + uint8_t enabledFlag = static_cast(data[DataView::ENABLED[DataView::OFFSET_INDEX]]); + uint64_t imuDateMs; + memcpy(&imuDateMs, data + DataView::IMU_DATE_MS[DataView::OFFSET_INDEX], sizeof(imuDateMs)); + imuDateMs = qFromLittleEndian(imuDateMs); + + const qint64 currentTimeMs = QDateTime::currentMSecsSinceEpoch(); + const bool updateConfig = lastConfigUpdate == 0 || currentTimeMs - lastConfigUpdate > 1000; + + if (updateConfig) { + float lookAheadConfig[4]; + memcpy(&lookAheadConfig[0], data + DataView::LOOK_AHEAD_CFG[DataView::OFFSET_INDEX], sizeof(lookAheadConfig)); + m_lookAheadConfig.clear(); + m_lookAheadConfig.append(lookAheadConfig[0]); + m_lookAheadConfig.append(lookAheadConfig[1]); + m_lookAheadConfig.append(lookAheadConfig[2]); + m_lookAheadConfig.append(lookAheadConfig[3]); + + uint32_t displayResolution[2]; + memcpy(&displayResolution[0], data + DataView::DISPLAY_RES[DataView::OFFSET_INDEX], sizeof(displayResolution)); + m_displayResolution.clear(); + m_displayResolution.append(displayResolution[0]); + m_displayResolution.append(displayResolution[1]); + + float displayFov = 0.0f; + memcpy(&displayFov, data + DataView::DISPLAY_FOV[DataView::OFFSET_INDEX], sizeof(displayFov)); + m_diagonalFOV = displayFov; + + float lensDistanceRatio = 0.0f; + memcpy(&lensDistanceRatio, data + DataView::LENS_DISTANCE_RATIO[DataView::OFFSET_INDEX], sizeof(lensDistanceRatio)); + m_lensDistanceRatio = lensDistanceRatio; + + uint8_t sbsEnabled = false; + memcpy(&sbsEnabled, data + DataView::SBS_ENABLED[DataView::OFFSET_INDEX], sizeof(sbsEnabled)); + m_sbsEnabled = (sbsEnabled != 0); + + uint8_t customBannerEnabled = false; + memcpy(&customBannerEnabled, data + DataView::CUSTOM_BANNER_ENABLED[DataView::OFFSET_INDEX], sizeof(customBannerEnabled)); + m_customBannerEnabled = (customBannerEnabled != 0); + + lastConfigUpdate = currentTimeMs; + } + + const bool validKeepAlive = (currentTimeMs - imuDateMs) < 5000; + const bool validData = validKeepAlive && m_diagonalFOV != 0.0f; + const uint8_t expectedVersion = 4; + bool enabledFlagSet = (enabledFlag != 0); + bool validVersion = (version == expectedVersion); + const bool wasEnabled = m_enabled; + const bool enabled = enabledFlagSet && validVersion && validData; + if (!enabled) { + // give a grace period after enabling the effect + if (wasEnabled && (currentTimeMs - activatedAt > 1000)) { + qCCritical(KWIN_XR) << "\t\t\tBreezy - disabling effect; currentTimeMs:" << currentTimeMs + << "imuDateMs:" << imuDateMs + << "enabledFlag:" << enabledFlag + << "version:" << version + << "diagonalFOV:" << m_diagonalFOV; + deactivate(); + m_enabled = false; + Q_EMIT enabledStateChanged(); + return; + } + } else if (!wasEnabled) { + qCCritical(KWIN_XR) << "\t\t\tBreezy - enabling effect; currentTimeMs:" << currentTimeMs + << "imuDateMs:" << imuDateMs + << "enabledFlag:" << enabledFlag + << "version:" << version + << "diagonalFOV:" << m_diagonalFOV; + activate(); + m_enabled = true; + Q_EMIT enabledStateChanged(); + activatedAt = currentTimeMs; + } + + if (updateConfig) Q_EMIT devicePropertiesChanged(); + + float imuData[4 * DataView::IMU_QUAT_ENTRIES]; // 4 quaternion-sized rows + memcpy(imuData, data + DataView::IMU_QUAT_DATA[DataView::OFFSET_INDEX], sizeof(imuData)); + bool wasImuResetState = m_imuResetState; + m_imuResetState = (imuData[0] == 0.0f && imuData[1] == 0.0f && imuData[2] == 0.0f && imuData[3] == 1.0f); + if (m_imuResetState != wasImuResetState) { + if (m_imuResetState) recenter(); + Q_EMIT imuResetStateChanged(); + } + + // convert NWU to EUS by passing root.rotation values: -y, z, -x + QQuaternion quatT0(imuData[3], -imuData[1], imuData[2], -imuData[0]); + + int imuDataOffset = DataView::IMU_QUAT_ENTRIES; + QQuaternion quatT1(imuData[imuDataOffset + 3], -imuData[imuDataOffset + 1], imuData[imuDataOffset + 2], -imuData[imuDataOffset + 0]); + + imuDataOffset += DataView::IMU_QUAT_ENTRIES; + + // skip the 3rd quaternion + imuDataOffset += DataView::IMU_QUAT_ENTRIES; + + // set imuRotations to the last two rotations, leave out the elapsed time + m_imuRotations.clear(); + m_imuRotations.append(quatT0); + m_imuRotations.append(quatT1); + + // 4th row isn't actually a quaternion, it contains the timestamps for each of the 3 quaternions + // elapsed time between T0 and T1 is: imuData[0] - imuData[1] + m_imuTimeElapsedMs = static_cast(imuData[imuDataOffset + 0] - imuData[imuDataOffset + 1]); + + m_imuTimestamp = imuDateMs; +} + +QString BreezyDesktopEffect::cursorImageSource() const +{ + return m_cursorImageSource; +} + +QSize BreezyDesktopEffect::cursorImageSize() const +{ + return m_cursorImageSize; +} + +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())); + m_cursorImageSize = cursor.image().size(); + } else { + m_cursorImageSource = QString(); + m_cursorImageSize = QSize(); + } + Q_EMIT cursorImageSourceChanged(); +} + +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 "breezydesktopeffect.moc" \ No newline at end of file diff --git a/kwin/src/breezydesktopeffect.h b/kwin/src/breezydesktopeffect.h new file mode 100644 index 0000000..f49dba8 --- /dev/null +++ b/kwin/src/breezydesktopeffect.h @@ -0,0 +1,148 @@ +#pragma once + +#include "kcm/shortcuts.h" +#include + +#include +#include +#include +#include +#include + +namespace KWin +{ + class BreezyDesktopEffect : public QuickSceneEffect + { + Q_OBJECT + Q_PROPERTY(bool isEnabled READ isEnabled NOTIFY enabledStateChanged) + Q_PROPERTY(bool zoomOnFocusEnabled READ isZoomOnFocusEnabled WRITE setZoomOnFocusEnabled NOTIFY zoomOnFocusChanged) + Q_PROPERTY(bool imuResetState READ imuResetState NOTIFY imuResetStateChanged) + Q_PROPERTY(QList imuRotations READ imuRotations) + Q_PROPERTY(quint32 imuTimeElapsedMs READ imuTimeElapsedMs) + Q_PROPERTY(quint64 imuTimestamp READ imuTimestamp) + Q_PROPERTY(QString cursorImageSource READ cursorImageSource NOTIFY cursorImageSourceChanged) + Q_PROPERTY(QSize cursorImageSize READ cursorImageSize NOTIFY cursorImageSourceChanged) + Q_PROPERTY(QPointF cursorPos READ cursorPos NOTIFY cursorPosChanged) + Q_PROPERTY(QList lookAheadConfig READ lookAheadConfig NOTIFY devicePropertiesChanged) + Q_PROPERTY(QList displayResolution READ displayResolution NOTIFY devicePropertiesChanged) + Q_PROPERTY(qreal focusedDisplayDistance READ focusedDisplayDistance NOTIFY focusedDisplayDistanceChanged) + Q_PROPERTY(qreal allDisplaysDistance READ allDisplaysDistance NOTIFY allDisplaysDistanceChanged) + Q_PROPERTY(qreal displaySpacing READ displaySpacing NOTIFY displaySpacingChanged) + Q_PROPERTY(qreal displayHorizontalOffset READ displayHorizontalOffset NOTIFY displayOffsetChanged) + Q_PROPERTY(qreal displayVerticalOffset READ displayVerticalOffset NOTIFY displayOffsetChanged) + Q_PROPERTY(int displayWrappingScheme READ displayWrappingScheme NOTIFY displayWrappingSchemeChanged) + 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) + Q_PROPERTY(int antialiasingQuality READ antialiasingQuality NOTIFY antialiasingQualityChanged) + Q_PROPERTY(bool removeVirtualDisplaysOnDisable READ removeVirtualDisplaysOnDisable NOTIFY removeVirtualDisplaysOnDisableChanged) + + public: + + BreezyDesktopEffect(); + ~BreezyDesktopEffect() override; + + void reconfigure(ReconfigureFlags) override; + + int requestedEffectChainPosition() const override; + + QString cursorImageSource() const; + QSize cursorImageSize() const; + QPointF cursorPos() const; + + bool isEnabled() const; + bool isZoomOnFocusEnabled() const; + void setZoomOnFocusEnabled(bool enabled); + QList imuRotations() const; + quint32 imuTimeElapsedMs() const; + quint64 imuTimestamp() const; + bool imuResetState() const; + QList lookAheadConfig() const; + QList displayResolution() const; + qreal focusedDisplayDistance() const; + void setFocusedDisplayDistance(qreal distance); + qreal allDisplaysDistance() const; + void setAllDisplaysDistance(qreal distance); + qreal displaySpacing() const; + void setDisplaySpacing(qreal spacing); + qreal displayHorizontalOffset() const; + qreal displayVerticalOffset() const; + int displayWrappingScheme() const; + qreal diagonalFOV() const; + qreal lensDistanceRatio() const; + bool sbsEnabled() const; + bool customBannerEnabled() const; + int antialiasingQuality() const; + bool removeVirtualDisplaysOnDisable() const; + + void showCursor(); + void hideCursor(); + + public Q_SLOTS: + void activate(); + void deactivate(); + void enableDriver(); + void disableDriver(); + void toggle(); + void addVirtualDisplay(QSize size); + void updateImuRotation(); + void updateCursorImage(); + void updateCursorPos(); + + Q_SIGNALS: + void focusedDisplayDistanceChanged(); + void allDisplaysDistanceChanged(); + void displaySpacingChanged(); + void displayOffsetChanged(); + void displayWrappingSchemeChanged(); + void enabledStateChanged(); + void zoomOnFocusChanged(); + void imuResetStateChanged(); + void cursorImageSourceChanged(); + void cursorPosChanged(); + void devicePropertiesChanged(); + void antialiasingQualityChanged(); + void removeVirtualDisplaysOnDisableChanged(); + + protected: + QVariantMap initialProperties(Output *screen) override; + + private: + void teardown(); + bool checkParityByte(const char* data); + void setupGlobalShortcut(const BreezyShortcuts::Shortcut &shortcut, + std::function triggeredFunc); + void recenter(); + + QString m_cursorImageSource; + QSize m_cursorImageSize; + + bool m_enabled = false; + bool m_zoomOnFocusEnabled = false; + bool m_imuResetState; + QList m_imuRotations; + quint32 m_imuTimeElapsedMs; + quint64 m_imuTimestamp = 0; + QList m_lookAheadConfig; + QList m_displayResolution; + qreal m_diagonalFOV; + qreal m_lensDistanceRatio; + bool m_sbsEnabled; + bool m_customBannerEnabled; + QFileSystemWatcher *m_shmFileWatcher = nullptr; + QFileSystemWatcher *m_shmDirectoryWatcher = nullptr; + QPointF m_cursorPos; + QTimer *m_cursorUpdateTimer = nullptr; + qreal m_focusedDisplayDistance = 0.85; + qreal m_allDisplaysDistance = 1.05; + qreal m_displaySpacing = 0.0; + qreal m_displayHorizontalOffset = 0.0; + qreal m_displayVerticalOffset = 0.0; + int m_displayWrappingScheme = 0; // 0=auto,1=horizontal,2=vertical,3=flat + int m_antialiasingQuality = 3; // 0=None, 1=Medium, 2=High, 3=VeryHigh + bool m_removeVirtualDisplaysOnDisable = true; + QList m_virtualOutputs; + }; + +} // namespace KWin diff --git a/kwin/src/kcm/CMakeLists.txt b/kwin/src/kcm/CMakeLists.txt new file mode 100644 index 0000000..1df5bc2 --- /dev/null +++ b/kwin/src/kcm/CMakeLists.txt @@ -0,0 +1,24 @@ +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 + Qt6::DBus + KF6::ConfigCore + KF6::ConfigGui + KF6::ConfigWidgets + KF6::CoreAddons + KF6::GlobalAccel + KF6::I18n + KF6::KCMUtils + KF6::XmlGui + + xr_driver_ipc +) + +# Ensure the version macro is available to the KCM as well (defined in parent CMakeLists) +if(BREEZY_DESKTOP_VERSION) + target_compile_definitions(breezy_desktop_config PRIVATE BREEZY_DESKTOP_VERSION_STR=\"${BREEZY_DESKTOP_VERSION}\") +endif() diff --git a/kwin/src/kcm/breezydesktopeffectkcm.cpp b/kwin/src/kcm/breezydesktopeffectkcm.cpp new file mode 100644 index 0000000..a3b62e4 --- /dev/null +++ b/kwin/src/kcm/breezydesktopeffectkcm.cpp @@ -0,0 +1,457 @@ +#include "shortcuts.h" +#include "breezydesktopeffectkcm.h" +#include "breezydesktopconfig.h" +#include "labeledslider.h" +#include "xrdriveripc.h" + +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +Q_LOGGING_CATEGORY(KWIN_XR, "kwin.xr") + +static const char EFFECT_GROUP[] = "Effect-breezy_desktop"; + +void addShortcutAction(KActionCollection *collection, const BreezyShortcuts::Shortcut &shortcut) +{ + QAction *action = collection->addAction(shortcut.actionName); + action->setText(shortcut.actionText); + action->setProperty("isConfigurationAction", true); + KGlobalAccel::self()->setDefaultShortcut(action, {shortcut.shortcut}); + KGlobalAccel::self()->setShortcut(action, {shortcut.shortcut}); +} + +K_PLUGIN_CLASS(BreezyDesktopEffectConfig) + +BreezyDesktopEffectConfig::BreezyDesktopEffectConfig(QObject *parent, const KPluginMetaData &data) + : KCModule(parent, data) +{ + ui.setupUi(widget()); + addConfig(BreezyDesktopConfig::self(), widget()); + + // Show/enable Virtual Display controls only when we're on Wayland + const bool isWaylandSession = QGuiApplication::platformName().contains(QStringLiteral("wayland"), Qt::CaseInsensitive) + || qEnvironmentVariable("XDG_SESSION_TYPE").compare(QStringLiteral("wayland"), Qt::CaseInsensitive) == 0; + if (isWaylandSession) { + if (auto lbl = widget()->findChild(QStringLiteral("labelVirtualDisplays"))) { + lbl->setVisible(true); + lbl->setEnabled(true); + } + if (auto row = widget()->findChild(QStringLiteral("widgetVirtualDisplayButtons"))) { + row->setVisible(true); + row->setEnabled(true); + } + if (auto chk = widget()->findChild(QStringLiteral("kcfg_RemoveVirtualDisplaysOnDisable"))) { + chk->setVisible(true); + chk->setEnabled(true); + } + } + + 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_EffectEnabled, &QCheckBox::toggled, this, &BreezyDesktopEffectConfig::updateDriverEnabled); + connect(ui.kcfg_ZoomOnFocusEnabled, &QCheckBox::toggled, this, &BreezyDesktopEffectConfig::save); + 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); + connect(ui.kcfg_DisplayHorizontalOffset, &QSlider::valueChanged, this, &BreezyDesktopEffectConfig::save); + connect(ui.kcfg_DisplayVerticalOffset, &QSlider::valueChanged, this, &BreezyDesktopEffectConfig::save); + connect(ui.kcfg_DisplayWrappingScheme, qOverload(&QComboBox::currentIndexChanged), this, &BreezyDesktopEffectConfig::save); + connect(ui.kcfg_AntialiasingQuality, qOverload(&QComboBox::currentIndexChanged), this, &BreezyDesktopEffectConfig::save); + connect(ui.kcfg_RemoveVirtualDisplaysOnDisable, &QCheckBox::toggled, this, &BreezyDesktopEffectConfig::save); + + if (auto label = widget()->findChild("labelAppNameVersion")) { + label->setText(QStringLiteral("Breezy Desktop - v%1").arg(QLatin1String(BREEZY_DESKTOP_VERSION_STR))); + } + + if (auto btnEmail = widget()->findChild("buttonSubmitEmail")) { + connect(btnEmail, &QPushButton::clicked, this, [this]() { + auto edit = widget()->findChild("lineEditLicenseEmail"); + auto labelStatus = widget()->findChild("labelEmailStatus"); + if (!edit || edit->text().trimmed().isEmpty() || !labelStatus) return; + setRequestInProgress({edit, sender()}, true); + labelStatus->setVisible(false); + bool success = XRDriverIPC::instance().requestToken(edit->text().trimmed().toStdString()); + showStatus(labelStatus, success, success ? tr("Request sent. Check your email for instructions.") : tr("Failed to send request.")); + setRequestInProgress({edit, sender()}, false); + }); + if (auto emailEdit = widget()->findChild("lineEditLicenseEmail")) { + emailEdit->installEventFilter(this); + } + } + if (auto btnToken = widget()->findChild("buttonSubmitToken")) { + connect(btnToken, &QPushButton::clicked, this, [this]() { + auto edit = widget()->findChild("lineEditLicenseToken"); + auto labelStatus = widget()->findChild("labelTokenStatus"); + if (!edit || edit->text().trimmed().isEmpty() || !labelStatus) return; + setRequestInProgress({edit, sender()}, true); + labelStatus->setVisible(false); + bool success = XRDriverIPC::instance().verifyToken(edit->text().trimmed().toStdString()); + if (success) { + XRDriverIPC::instance().writeControlFlags({{"refresh_device_license", true}}); + } + showStatus(labelStatus, success, success ? tr("Your license has been refreshed.") : tr("Invalid or expired token.")); + setRequestInProgress({edit, sender()}, false); + }); + if (auto tokenEdit = widget()->findChild("lineEditLicenseToken")) { + tokenEdit->installEventFilter(this); + } + } + + // Wire Add Virtual Display buttons via DBus to the effect + auto callAddVirtualDisplay = [](int w, int h) { + QDBusInterface iface( + QStringLiteral("org.kde.KWin"), + QStringLiteral("/com/xronlinux/BreezyDesktop"), + QStringLiteral("com.xronlinux.BreezyDesktop"), + QDBusConnection::sessionBus()); + if (iface.isValid()) { + iface.call(QDBus::NoBlock, QStringLiteral("AddVirtualDisplay"), w, h); + } + }; + if (auto btn1080p = widget()->findChild("buttonAdd1080p")) { + connect(btn1080p, &QPushButton::clicked, this, [callAddVirtualDisplay]() { + callAddVirtualDisplay(1920, 1080); + }); + } + if (auto btn1440p = widget()->findChild("buttonAdd1440p")) { + connect(btn1440p, &QPushButton::clicked, this, [callAddVirtualDisplay]() { + callAddVirtualDisplay(2560, 1440); + }); + } + + // General tab: Open KDE Displays Settings + if (auto btnDisplays = widget()->findChild(QStringLiteral("buttonOpenDisplaysSettings"))) { + connect(btnDisplays, &QPushButton::clicked, this, [this]() { + // Try launching the KScreen KCM + if (!QProcess::startDetached(QStringLiteral("kcmshell6"), {QStringLiteral("kcm_kscreen")})) { + QDesktopServices::openUrl(QUrl(QStringLiteral("systemsettings://kcm_kscreen"))); + } + }); + } +} + +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_DisplayHorizontalOffset->setValue(BreezyDesktopConfig::self()->displayHorizontalOffset()); + ui.kcfg_DisplayVerticalOffset->setValue(BreezyDesktopConfig::self()->displayVerticalOffset()); + ui.kcfg_DisplayWrappingScheme->setCurrentIndex(BreezyDesktopConfig::self()->displayWrappingScheme()); + ui.kcfg_AntialiasingQuality->setCurrentIndex(BreezyDesktopConfig::self()->antialiasingQuality()); + ui.kcfg_RemoveVirtualDisplaysOnDisable->setChecked(BreezyDesktopConfig::self()->removeVirtualDisplaysOnDisable()); + 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::updateDriverEnabled() +{ + if (driverEnabled() == ui.kcfg_EffectEnabled->isChecked()) { + return; + } + + QJsonObject newConfig = QJsonObject(); + auto configJsonOpt = XRDriverIPC::instance().retrieveConfig(); + if (configJsonOpt) { + newConfig = configJsonOpt.value(); + } + if (ui.kcfg_EffectEnabled->isChecked()) { + newConfig.insert(QStringLiteral("disabled"), false); + newConfig.insert(QStringLiteral("output_mode"), QStringLiteral("external_only")); + newConfig.insert(QStringLiteral("external_mode"), QStringLiteral("breezy_desktop")); + } else { + newConfig.insert(QStringLiteral("external_mode"), QStringLiteral("none")); + } + XRDriverIPC::instance().writeConfig(newConfig); +} + +bool BreezyDesktopEffectConfig::driverEnabled() +{ + auto configJsonOpt = XRDriverIPC::instance().retrieveConfig(); + if (!configJsonOpt) return false; + auto configJson = configJsonOpt.value(); + bool driverDisabled = configJson.value(QStringLiteral("disabled")).toBool(); + QString driverOutputMode = configJson.value(QStringLiteral("output_mode")).toString(); + QJsonArray driverExternalMode = configJson.value(QStringLiteral("external_mode")).toArray(); + return !driverDisabled && + driverOutputMode == QStringLiteral("external_only") && + driverExternalMode.contains(QJsonValue(QStringLiteral("breezy_desktop"))); +} + +void BreezyDesktopEffectConfig::pollDriverState() +{ + auto &bridge = XRDriverIPC::instance(); + auto stateJsonOpt = bridge.retrieveDriverState(); + if (!stateJsonOpt) return; + auto stateJson = stateJsonOpt.value(); + m_connectedDeviceBrand = stateJson.value(QStringLiteral("connected_device_brand")).toString(); + m_connectedDeviceModel = stateJson.value(QStringLiteral("connected_device_model")).toString(); + + const bool wasDeviceConnected = m_deviceConnected; + m_deviceConnected = !m_connectedDeviceBrand.isEmpty() && !m_connectedDeviceModel.isEmpty(); + if (ui.labelDeviceConnectionStatus->text().isEmpty() || m_deviceConnected != wasDeviceConnected) { + ui.labelDeviceConnectionStatus->setText(m_deviceConnected ? + QStringLiteral("%1 %2 connected").arg(m_connectedDeviceBrand, m_connectedDeviceModel) : + QStringLiteral("No device connected")); + } + + bool effectEnabled = driverEnabled(); + if (ui.kcfg_EffectEnabled->isChecked() != effectEnabled) ui.kcfg_EffectEnabled->setChecked(effectEnabled); + + refreshLicenseUi(stateJson); +} + +void BreezyDesktopEffectConfig::showStatus(QLabel *label, bool success, const QString &message) { + if (!label) return; + QPalette pal = label->palette(); + pal.setColor(QPalette::WindowText, success ? QColor(Qt::darkGreen) : QColor(Qt::red)); + label->setPalette(pal); + label->setText(message); + label->setVisible(true); +} + +void BreezyDesktopEffectConfig::setRequestInProgress(std::initializer_list widgets, bool inProgress) { + for (auto *obj : widgets) { + if (auto *w = qobject_cast(obj)) { + w->setEnabled(!inProgress); + } + } +} + +bool BreezyDesktopEffectConfig::eventFilter(QObject *watched, QEvent *event) { + if (event->type() == QEvent::KeyPress) { + auto *ke = static_cast(event); + if (ke->key() == Qt::Key_Return || ke->key() == Qt::Key_Enter) { + if (auto *edit = qobject_cast(watched)) { + // Determine which button to invoke + QString objName = edit->objectName(); + QString buttonName; + if (objName == QLatin1String("lineEditLicenseEmail")) buttonName = QStringLiteral("buttonSubmitEmail"); + else if (objName == QLatin1String("lineEditLicenseToken")) buttonName = QStringLiteral("buttonSubmitToken"); + if (!buttonName.isEmpty()) { + if (auto btn = widget()->findChild(buttonName)) { + // Trigger click but stop further propagation so dialog doesn't accept/close + QMetaObject::invokeMethod(btn, "click", Qt::QueuedConnection); + event->accept(); + return true; // eat event + } + } + } + } + } + return KCModule::eventFilter(watched, event); +} + +static QString secondsToRemainingString(qint64 secs) { + if (secs <= 0) return {}; + + if (secs / 60 < 60) { + return QObject::tr("less than an hour"); + } + if (secs / 3600 < 24) { + qint64 hours = secs / 3600; + if (hours == 1) return QObject::tr("1 hour"); + return QObject::tr("%1 hours").arg(hours); + } + if ((secs / 86400) < 30 ) { + qint64 days = secs / 86400; + if (days == 1) return QObject::tr("1 day"); + return QObject::tr("%1 days").arg(days); + } + return {}; +} + +void BreezyDesktopEffectConfig::refreshLicenseUi(const QJsonObject &rootObj) { + auto tab = widget()->findChild("tabLicenseDetails"); + if (!tab) return; + auto labelSummary = tab->findChild("labelLicenseSummary"); + if (!labelSummary) return; + auto donate = tab->findChild("labelDonateLink"); + auto globalWarn = widget()->findChild("labelGlobalLicenseWarning"); + + QString status = tr("disabled"); + QString renewalDescriptor = QStringLiteral(""); + auto uiView = rootObj.value(QStringLiteral("ui_view")).toObject(); + auto license = uiView.value(QStringLiteral("license")).toObject(); + bool warningState = false; + bool expired = false; + if (!license.isEmpty()) { + auto tiers = license.value(QStringLiteral("tiers")).toObject(); + QJsonValue prodTier = tiers.value(QStringLiteral("subscriber")); + QJsonObject prodTierObj = prodTier.isUndefined() ? QJsonObject() : prodTier.toObject(); + + auto features = license.value(QStringLiteral("features")).toObject(); + QJsonValue prodFeature = features.value(QStringLiteral("productivity_basic")); + QJsonObject prodFeatureObj = prodFeature.isUndefined() ? QJsonObject() : prodFeature.toObject(); + if (!prodTierObj.isEmpty() && !prodFeatureObj.isEmpty()) { + const QString activePeriod = prodTierObj.value(QStringLiteral("active_period")).toString(); + const bool isActive = !activePeriod.isEmpty(); + if (isActive) { + status = tr("active"); + + QString periodDescriptor = activePeriod.contains(QStringLiteral("lifetime"), Qt::CaseInsensitive) ? + tr("lifetime") : + tr("%1 license").arg(activePeriod); + + QString timeDescriptor; + auto secsVal = prodTierObj.value(QStringLiteral("funds_needed_in_seconds")); + if (secsVal.isDouble()) { + qint64 secs = static_cast(secsVal.toDouble()); + QString remaining = secondsToRemainingString(secs); + if (!remaining.isEmpty()) { + timeDescriptor = tr("%1 remaining").arg(remaining); + } + } + renewalDescriptor = tr(" (%1)").arg(periodDescriptor); + warningState = !timeDescriptor.isEmpty(); + if (warningState) { + auto fundsNeeded = prodTierObj.value(QStringLiteral("funds_needed_by_period")).toObject().value(activePeriod).toDouble(); + if (fundsNeeded > 0.0) { + QString fundsNeededDescriptor = tr("$%1 USD to renew").arg(fundsNeeded); + renewalDescriptor = tr(" (%1, %2, %3)").arg(periodDescriptor, fundsNeededDescriptor, timeDescriptor); + } + } + } else { + QJsonValue isEnabled = prodFeatureObj.value(QStringLiteral("is_enabled")); + QJsonValue isTrial = prodFeatureObj.value(QStringLiteral("is_trial")); + if (isEnabled.toBool()) { + if (isTrial.toBool()) { + status = tr("in trial"); + auto secsVal = prodFeatureObj.value(QStringLiteral("funds_needed_in_seconds")); + if (secsVal.isDouble()) { + qint64 secs = static_cast(secsVal.toDouble()); + QString remaining = secondsToRemainingString(secs); + warningState = !remaining.isEmpty(); + if (warningState) { + QString timeDescriptor = tr("%1 remaining").arg(remaining); + renewalDescriptor = tr(" (%1)").arg(timeDescriptor); + } + } + } + } else { + expired = true; + } + } + } + } + const QString message = tr("Productivity Tier features are %1%2").arg(status, renewalDescriptor); + labelSummary->setText(message); + + if (donate) donate->setVisible(warningState || expired); + + if (globalWarn) { + if (warningState || expired) { + globalWarn->setText(message + (expired ? tr(" — effect disabled") : QString())); + globalWarn->setVisible(true); + } else { + globalWarn->clear(); + globalWarn->setVisible(false); + } + } + + if (expired) { + if (ui.tabWidget) ui.tabWidget->setEnabled(false); + OrgKdeKwinEffectsInterface interface(QStringLiteral("org.kde.KWin"), QStringLiteral("/Effects"), QDBusConnection::sessionBus()); + interface.unloadEffect(QStringLiteral("breezy_desktop")); + } else { + if (ui.tabWidget) ui.tabWidget->setEnabled(true); + } +} + +#include "breezydesktopeffectkcm.moc" \ No newline at end of file diff --git a/kwin/src/kcm/breezydesktopeffectkcm.h b/kwin/src/kcm/breezydesktopeffectkcm.h new file mode 100644 index 0000000..1a54f4b --- /dev/null +++ b/kwin/src/kcm/breezydesktopeffectkcm.h @@ -0,0 +1,49 @@ +#pragma once + +#include +#include +#include + +#include + +#include "ui_breezydesktopeffectkcm.h" + +class KConfigWatcher; +class KConfigGroup; + +class BreezyDesktopEffectConfig : public KCModule +{ + Q_OBJECT + +public: + BreezyDesktopEffectConfig(QObject *parent, const KPluginMetaData &data); + ~BreezyDesktopEffectConfig() override; + +public Q_SLOTS: + void load() override; + void save() override; + void defaults() override; + +private: + void updateDriverEnabled(); + void updateUiFromConfig(); + void updateUiFromDefaultConfig(); + void updateConfigFromUi(); + void updateUnmanagedState(); + bool driverEnabled(); + void pollDriverState(); + void refreshLicenseUi(const QJsonObject &rootObj); + void showStatus(QLabel *label, bool success, const QString &message); + void setRequestInProgress(std::initializer_list widgets, bool inProgress); + bool eventFilter(QObject *watched, QEvent *event) override; + + ::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 + bool m_licenseLoading = false; +}; diff --git a/kwin/src/kcm/breezydesktopeffectkcm.ui b/kwin/src/kcm/breezydesktopeffectkcm.ui new file mode 100644 index 0000000..1b57da3 --- /dev/null +++ b/kwin/src/kcm/breezydesktopeffectkcm.ui @@ -0,0 +1,529 @@ + + + BreezyDesktopEffectConfig + + + + + + + + + Qt::AlignHCenter|Qt::AlignVCenter + + + + 14 + 75 + true + + + + + + + + + + + false + + + true + + + Qt::AlignHCenter|Qt::AlignVCenter + + + color: rgb(200,0,0); font-weight: bold; + + + + + + + QTabWidget::North + + + QTabWidget::Rounded + + + + &General + + + + + + XR Effect enabled + + + true + + + + + + + Zoom on Focus + + + false + + + + + + + Focused Display Distance: + + + + + + + 2 + + + QSlider::TicksBelow + + + 25 + + + Qt::Horizontal + + + true + + + + + + + All Displays Distance: + + + + + + + 2 + + + QSlider::TicksBelow + + + 25 + + + Qt::Horizontal + + + true + + + + + + + Display Spacing: + + + + + + + Qt::Horizontal + + + true + + + + + + + Add Virtual Display: + + + false + + + false + + + + + + + false + + + false + + + + + + + 1080p + + + + + + + + 1440p + + + + + + + Rearrange displays + + + + + + + + + + + 0 + 0 + + + + + + + + + &Advanced + + + + + + Display Wrapping Scheme: + + + + + + + + Auto + + + + + Horizontal + + + + + Vertical + + + + + Flat + + + + + + + + Anti-aliasing quality: + + + + + + + + None + + + + + Medium + + + + + High + + + + + Very High + + + + + + + + Display Horizontal Offset: + + + + + + + 2 + + + QSlider::TicksBelow + + + 50 + + + Qt::Horizontal + + + true + + + + + + + Display Vertical Offset: + + + + + + + 2 + + + QSlider::TicksBelow + + + 50 + + + Qt::Horizontal + + + true + + + + + + + false + + + false + + + Remove virtual displays on disable + + true + + + + + + + &License Details + + + + + + + + + true + + + true + + + + + + + <a href="https://ko-fi.com/wheaney">Renew or support on Ko‑fi</a> + + + true + + + Qt::AlignHCenter|Qt::AlignVCenter + + + false + + + + + + + Request a token + + + + + + you@example.com + + + + + + + Submit + + + + + + + + + + false + + + true + + + + + + + + + + Verify token + + + + + + + + + Verify + + + + + + + + + + false + + + true + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + &About + + + + + + Breezy Desktop Effect - v0.0.0 + + + Qt::AlignHCenter|Qt::AlignVCenter + + + + 14 + 75 + true + + + + + + + + Author: Wayne Heaney <wayne@xronlinux.com> + + + Qt::AlignHCenter|Qt::AlignVCenter + + + + + + + License: GPL-3.0 + + + Qt::AlignHCenter|Qt::AlignVCenter + + + true + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + + + KShortcutsEditor + QWidget +
kshortcutseditor.h
+ 1 +
+
+ + + +
diff --git a/kwin/src/kcm/labeledslider.cpp b/kwin/src/kcm/labeledslider.cpp new file mode 100644 index 0000000..23cdc32 --- /dev/null +++ b/kwin/src/kcm/labeledslider.cpp @@ -0,0 +1 @@ +#include "labeledslider.h" \ No newline at end of file diff --git a/kwin/src/kcm/labeledslider.h b/kwin/src/kcm/labeledslider.h new file mode 100644 index 0000000..77a62e0 --- /dev/null +++ b/kwin/src/kcm/labeledslider.h @@ -0,0 +1,159 @@ +#pragma once + +#include +#include +#include +#include +#include // for std::max + +/* + * LabeledSlider + * Horizontal QSlider that draws numeric labels at tick intervals and (optionally) a value bubble. + * Usage: + * auto *s = new LabeledSlider(parent); + * s->setMinimum(20); + * s->setMaximum(250); + * s->setTickInterval(20); + * s->setTickPosition(QSlider::TicksBelow); + * s->setShowValueBubble(true); + */ + +class LabeledSlider : public QSlider { + Q_OBJECT + Q_PROPERTY(bool showValueBubble READ showValueBubble WRITE setShowValueBubble) + // decimalShift: number of places to shift the decimal point left for display ONLY. + // Example: raw value 250 with decimalShift=2 displays as 2.50. Underlying slider value + // (signals, stored config) remains 250. + Q_PROPERTY(int decimalShift READ decimalShift WRITE setDecimalShift) +public: + explicit LabeledSlider(QWidget *parent = nullptr) + : QSlider(Qt::Horizontal, parent) + { + setTickPosition(QSlider::TicksBelow); + } + + bool showValueBubble() const { return m_showValueBubble; } + void setShowValueBubble(bool on) { + if (m_showValueBubble == on) return; + m_showValueBubble = on; + update(); + } + + int decimalShift() const { return m_decimalShift; } + void setDecimalShift(int shift) { + // clamp to sensible range + if (shift < 0) shift = 0; + if (shift > 6) shift = 6; // avoid large power-of-10 overflow + if (m_decimalShift == shift) return; + m_decimalShift = shift; + updateGeometry(); + update(); + } + + QSize sizeHint() const override { + QSize sz = QSlider::sizeHint(); + int extraH = 0; + if (labelInterval() > 0) { + // Reserve space for bottom labels + QFontMetrics fm(font()); + extraH += fm.height() + 4; + } + if (m_showValueBubble) { + QFontMetrics fm(font()); + extraH = std::max(extraH, fm.height() + 8); // bubble might be above + } + sz.setHeight(sz.height() + extraH); + return sz; + } + +protected: + void paintEvent(QPaintEvent *e) override { + QSlider::paintEvent(e); + + QStyleOptionSlider opt; + initStyleOption(&opt); + + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing, true); + + const int minV = minimum(); + const int maxV = maximum(); + + // Draw labels below ticks + if (labelInterval() > 0) { + QFontMetrics fm(font()); + const int baselineY = height() - fm.descent() - 1; + int interval = labelInterval(); + for (int v = minV; v <= maxV; v += interval) { + // Use style geometry for handle at this position to match tick placement. + QStyleOptionSlider optPos = opt; + optPos.sliderPosition = v; + optPos.sliderValue = v; + QRect handleAtVal = style()->subControlRect(QStyle::CC_Slider, &optPos, QStyle::SC_SliderHandle, this); + int x = handleAtVal.center().x(); + QString text = valueToDisplayString(v); + int halfW = fm.horizontalAdvance(text) / 2; + QRect r(x - halfW, baselineY - fm.ascent(), fm.horizontalAdvance(text), fm.height()); + p.drawText(r, Qt::AlignCenter, text); + } + } + + // Draw floating value bubble over handle + if (m_showValueBubble) { + // Handle rect + const QRect handle = style()->subControlRect(QStyle::CC_Slider, &opt, QStyle::SC_SliderHandle, this); + QString valText = valueToDisplayString(value()); + QFontMetrics fm(font()); + QRect textRect = fm.boundingRect(valText); + textRect.adjust(-6, -4, 6, 4); + + // Position bubble above the handle; add extra lift + const int extraLift = 10; // requested additional pixels + const int gap = 4; // minimal gap between handle top and bubble + int topY = handle.top() - gap - extraLift - textRect.height(); + if (topY < 0) topY = 0; // clamp to widget + textRect.moveTop(topY); + textRect.moveLeft(handle.center().x() - textRect.width()/2); + + // Bubble shape + QPainterPath path; + path.addRoundedRect(textRect, 6, 6); + + p.setPen(Qt::NoPen); + p.setBrush(palette().toolTipBase()); + p.drawPath(path); + + p.setPen(palette().toolTipText().color()); + p.drawText(textRect, Qt::AlignCenter, valText); + } + } + +private: + QString valueToDisplayString(int raw) const { + if (m_decimalShift == 0) { + return QString::number(raw); + } + int divisor = 1; + for (int i = 0; i < m_decimalShift; ++i) divisor *= 10; + int whole = raw / divisor; + int frac = std::abs(raw % divisor); + QString fracStr = QString::number(frac).rightJustified(m_decimalShift, QLatin1Char('0')); + QString result = QString::number(std::abs(whole)) + QLatin1Char('.') + fracStr; + if (raw < 0) result.prepend(QLatin1Char('-')); + return result; + } + + bool m_showValueBubble = true; + int m_decimalShift = 0; // display-only decimal shift +private: + int labelInterval() const { + int ti = tickInterval(); + if (ti > 0) return ti; + // Heuristic fallback: divide range into ~10 segments. + int range = maximum() - minimum(); + if (range <= 0) return 0; + int approx = range / 10; + if (approx <= 0) approx = range; // single label at ends + return approx; + } +}; \ No newline at end of file diff --git a/kwin/src/kcm/shortcuts.h b/kwin/src/kcm/shortcuts.h new file mode 100644 index 0000000..f5792a6 --- /dev/null +++ b/kwin/src/kcm/shortcuts.h @@ -0,0 +1,31 @@ +#pragma once + +#include +#include +#include + +namespace BreezyShortcuts { + struct Shortcut { + QKeySequence shortcut; + QString actionName; + QString actionText; + }; + + const Shortcut TOGGLE = { + Qt::CTRL | Qt::META | Qt::Key_Backslash, + QStringLiteral("Toggle XR Effect"), + QStringLiteral("Toggle XR Effect") + }; + + const Shortcut RECENTER = { + Qt::CTRL | Qt::META | Qt::Key_Space, + QStringLiteral("Recenter"), + QStringLiteral("Recenter") + }; + + const Shortcut TOGGLE_ZOOM_ON_FOCUS = { + Qt::CTRL | Qt::META | Qt::Key_0, + QStringLiteral("Toggle Zoom on Focus"), + QStringLiteral("Toggle Zoom on Focus") + }; +} diff --git a/kwin/src/main.cpp b/kwin/src/main.cpp new file mode 100644 index 0000000..fd243a0 --- /dev/null +++ b/kwin/src/main.cpp @@ -0,0 +1,10 @@ +#include "breezydesktopeffect.h" + +namespace KWin +{ + +KWIN_EFFECT_FACTORY(BreezyDesktopEffect, "metadata.json") + +} // namespace KWin + +#include "main.moc" diff --git a/kwin/src/metadata.json b/kwin/src/metadata.json new file mode 100644 index 0000000..8d582c4 --- /dev/null +++ b/kwin/src/metadata.json @@ -0,0 +1,17 @@ +{ + "KPackageStructure": "KWin/Effect", + "KPlugin": { + "Authors": [ + { + "Email": "wayne@xronlinux.com", + "Name": "Wayne Heaney" + } + ], + "Category": "Tools", + "Description": "Breezy Desktop XR Effect", + "EnabledByDefault": true, + "License": "GPL", + "Name": "Breezy Desktop XR" + }, + "X-KDE-ConfigModule": "breezy_desktop_config" +} diff --git a/kwin/src/qml/BreezyDesktop.qml b/kwin/src/qml/BreezyDesktop.qml new file mode 100644 index 0000000..4520c1e --- /dev/null +++ b/kwin/src/qml/BreezyDesktop.qml @@ -0,0 +1,171 @@ +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 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: breezyDesktop.screens.length + delegate: BreezyDesktopDisplay { + screen: breezyDesktop.screens[index] + monitorPlacement: breezyDesktop.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 matrix4x4 rotationMatrix: { + const matrix = Qt.matrix4x4(); + matrix.rotate(screenRotationY, Qt.vector3d(0, 1, 0)); + matrix.rotate(screenRotationX, Qt.vector3d(1, 0, 0)); + return matrix; + } + + 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: { + const displayNwu = + monitorPlacement.centerNoRotate + .times(monitorDistance / effect.allDisplaysDistance); + + + return rotationMatrix.times(displays.nwuToEusVector(displayNwu)); + } + } + } + + Timer { + interval: 500 // 500ms - 2x per second to avoid running this check too frequently + repeat: true + running: true + onTriggered: { + if (effect.imuRotations && effect.imuRotations.length > 0) { + let focusedIndex = -1; + + if (effect.zoomOnFocusEnabled) { + focusedIndex = displays.findFocusedMonitor( + displays.eusToNwuQuat(effect.imuRotations[0]), + breezyDesktop.monitorPlacements.map(monitorVectors => monitorVectors.centerLook), + breezyDesktop.focusedMonitorIndex, + false, // TODO smooth follow + breezyDesktop.fovDetails, + breezyDesktop.screens.map(screen => screen.geometry) + ); + } + + if (focusedIndex !== breezyDesktop.focusedMonitorIndex) { + const unfocusedIndex = breezyDesktop.focusedMonitorIndex; + const focusedDisplay = focusedIndex !== -1 ? breezyDesktop.displayAtIndex(focusedIndex) : null; + const allDisplaysDistanceBinding = Qt.binding(function() { return effect.allDisplaysDistance; }); + const focusedDisplayDistanceBinding = Qt.binding(function() { return effect.focusedDisplayDistance; }); + if (focusedDisplay === null) { + const unfocusedDisplay = breezyDesktop.displayAtIndex(unfocusedIndex); + zoomOutAnimation.target = unfocusedDisplay; + zoomOutAnimation.target.targetDistance = effect.allDisplaysDistance; + zoomOutAnimation.start(); + } else { + if (unfocusedIndex === -1) { + zoomInAnimation.target = focusedDisplay; + focusedDisplay.targetDistance = effect.focusedDisplayDistance; + zoomInAnimation.start(); + } else { + zoomInSeqAnimation.target = focusedDisplay; + focusedDisplay.targetDistance = effect.focusedDisplayDistance; + + const unfocusedDisplay = breezyDesktop.displayAtIndex(unfocusedIndex); + zoomOutSeqAnimation.target = unfocusedDisplay; + zoomOutSeqAnimation.target.targetDistance = effect.allDisplaysDistance; + + zoomOnFocusSequence.start(); + } + } + breezyDesktop.focusedMonitorIndex = focusedIndex; + } + } + } + } + + NumberAnimation { + id: zoomOutAnimation + property: "monitorDistance" + to: effect.allDisplaysDistance + duration: 150 + running: false + onFinished: { + const unfocusedDisplay = zoomOutAnimation.target; + if (unfocusedDisplay) { + unfocusedDisplay.monitorDistance = Qt.binding(function() { return effect.allDisplaysDistance; }); + } + } + } + + NumberAnimation { + id: zoomInAnimation + property: "monitorDistance" + to: effect.focusedDisplayDistance + duration: 300 + running: false + onFinished: { + const focusedDisplay = zoomInAnimation.target; + if (focusedDisplay) { + focusedDisplay.monitorDistance = Qt.binding(function() { return effect.focusedDisplayDistance; }); + } + } + } + + SequentialAnimation { + id: zoomOnFocusSequence + running: false + onFinished: { + const focusedDisplay = zoomInSeqAnimation.target; + if (focusedDisplay) { + focusedDisplay.monitorDistance = Qt.binding(function() { return effect.focusedDisplayDistance; }); + } + const unfocusedDisplay = zoomOutSeqAnimation.target; + if (unfocusedDisplay) { + unfocusedDisplay.monitorDistance = Qt.binding(function() { return effect.allDisplaysDistance; }); + } + } + + NumberAnimation { + id: zoomOutSeqAnimation + property: "monitorDistance" + to: effect.allDisplaysDistance + duration: 150 + } + PauseAnimation { duration: 50 } + NumberAnimation { + id: zoomInSeqAnimation + property: "monitorDistance" + to: effect.focusedDisplayDistance + duration: 300 + } + } +} diff --git a/kwin/src/qml/BreezyDesktopDisplay.qml b/kwin/src/qml/BreezyDesktopDisplay.qml new file mode 100644 index 0000000..e192c11 --- /dev/null +++ b/kwin/src/qml/BreezyDesktopDisplay.qml @@ -0,0 +1,53 @@ +import QtQuick +import QtQuick3D + +Model { + id: display + + required property QtObject screen + required property var monitorPlacement + required property int index + + property string cursorImageSource: effect.cursorImageSource + property size cursorImageSize: effect.cursorImageSize + property point cursorPos: effect.cursorPos + + source: "#Rectangle" + materials: [ + CustomMaterial { + id: customMat + depthDrawMode: CustomMaterial.AlwaysDepthDraw + shadingMode: CustomMaterial.Unshaded + + property real screenWidth: display.screen.geometry.width + property real screenHeight: display.screen.geometry.height + property real cursorX: display.cursorPos.x - display.screen.geometry.x + property real cursorY: display.cursorPos.y - display.screen.geometry.y + property real cursorW: display.cursorImageSize.width + property real cursorH: display.cursorImageSize.height + property bool showCursor: cursorX >= 0 && cursorX < screenWidth && cursorY >= 0 && cursorY < screenHeight + + property TextureInput desktopTex: TextureInput { + texture: Texture { + sourceItem: DesktopView { + screen: display.screen + width: display.screen.geometry.width + height: display.screen.geometry.height + } + } + } + property TextureInput cursorTex: TextureInput { + texture: Texture { + sourceItem: Image { + source: effect.cursorImageSource + width: effect.cursorImageSize.width + height: effect.cursorImageSize.height + } + } + } + + fragmentShader: "cursorOverlay.frag" + vertexShader: "cursorOverlay.vert" + } + ] +} diff --git a/kwin/src/qml/CameraController.qml b/kwin/src/qml/CameraController.qml new file mode 100644 index 0000000..ce69a9c --- /dev/null +++ b/kwin/src/qml/CameraController.qml @@ -0,0 +1,80 @@ +import QtQuick +import QtQuick3D + +Item { + id: root + + required property Camera camera + + 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 (effect.imuRotations && effect.imuRotations.length > 0) { + updateCamera(applyLookAhead( + effect.imuRotations[0], + effect.imuRotations[1], + effect.imuTimeElapsedMs, + lookAheadMS(effect.imuTimestamp, effect.lookAheadConfig, -1) + )); + } + } + } +} diff --git a/kwin/src/qml/DesktopView.qml b/kwin/src/qml/DesktopView.qml new file mode 100644 index 0000000..10d9940 --- /dev/null +++ b/kwin/src/qml/DesktopView.qml @@ -0,0 +1,43 @@ +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 + } + } +} diff --git a/kwin/src/qml/Displays.qml b/kwin/src/qml/Displays.qml new file mode 100644 index 0000000..dee70b6 --- /dev/null +++ b/kwin/src/qml/Displays.qml @@ -0,0 +1,405 @@ +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, defaultDisplayDistance, wrappingChoice) { + const aspect = viewportWidth / viewportHeight; + const fovRadians = diagonalToCrossFOVs(degreeToRadian(viewportDiagonalFOV), aspect); + const defaultDistanceVerticalRadians = 2 * Math.atan(Math.tan(fovRadians.vertical / 2) / defaultDisplayDistance); + const defaultDistanceHorizontalRadians = 2 * Math.atan(Math.tan(fovRadians.horizontal / 2) / defaultDisplayDistance); + + // 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; + + let monitorWrappingScheme = actualWrapScheme(screens, viewportWidth, viewportHeight); + if (wrappingChoice === 1) monitorWrappingScheme = 'horizontal'; + else if (wrappingChoice === 2) monitorWrappingScheme = 'vertical'; + else if (wrappingChoice === 3) monitorWrappingScheme = 'flat'; + + return { + widthPixels: viewportWidth, + heightPixels: viewportHeight, + defaultDistanceVerticalRadians, + defaultDistanceHorizontalRadians, + lensDistancePixels, + completeScreenDistancePixels, + monitorWrappingScheme: monitorWrappingScheme, + curvedDisplay: false // or true + }; + } + + // Utility constant + readonly property real segmentsPerRadian: 20.0 / degreeToRadian(90.0) + + // FOV conversion functions for flat and curved displays + property var fovConversionFns: ({ + flat: { + centerToFovEdgeDistance: function(centerDistance, fovLength) { + return Math.sqrt(Math.pow(fovLength / 2, 2) + Math.pow(centerDistance, 2)); + }, + fovEdgeToScreenCenterDistance: function(edgeDistance, screenLength) { + return Math.sqrt(Math.pow(edgeDistance, 2) - Math.pow(screenLength / 2, 2)); + }, + lengthToRadians: function(fovRadians, fovLength, screenEdgeDistance, toLength) { + return Math.asin(toLength / 2 / screenEdgeDistance) * 2; + }, + angleToLength: function(fovRadians, fovLength, screenDistance, toAngleOpposite, toAngleAdjacent) { + return toAngleOpposite / toAngleAdjacent * screenDistance; + }, + radiansToSegments: function(screenRadians) { return 1; } + }, + curved: { + centerToFovEdgeDistance: function(centerDistance, fovLength) { + return centerDistance; + }, + fovEdgeToScreenCenterDistance: function(edgeDistance, screenLength) { + return edgeDistance; + }, + lengthToRadians: function(fovRadians, fovLength, screenEdgeDistance, toLength) { + return fovRadians / fovLength * toLength; + }, + angleToLength: function(fovRadians, fovLength, screenDistance, toAngleOpposite, toAngleAdjacent) { + return fovLength / fovRadians * Math.atan2(toAngleOpposite, toAngleAdjacent); + }, + radiansToSegments: function(screenRadians) { + return Math.ceil(screenRadians * segmentsPerRadian); + } + } + }) + + function monitorWrap(cachedMonitorRadians, monitorSpacingPixels, monitorBeginPixel, monitorLengthPixels, lengthToRadianFn) { + var closestWrapPixel = monitorBeginPixel; + var closestWrap = cachedMonitorRadians[monitorBeginPixel]; + if (closestWrap === undefined) { + var keys = Object.keys(cachedMonitorRadians); + closestWrapPixel = keys.reduce(function(previousPixel, currentPixel) { + if (previousPixel === undefined) return currentPixel; + + var currentDelta = currentPixel - monitorBeginPixel; + var previousDelta = previousPixel - monitorBeginPixel; + + if (previousDelta % monitorLengthPixels !== 0) { + if (currentDelta % monitorLengthPixels === 0) return currentPixel; + if (previousDelta < 0 && currentDelta > 0) return currentPixel; + if (Math.abs(currentDelta) < Math.abs(previousDelta)) return currentPixel; + } + return previousPixel; + }, undefined); + closestWrap = cachedMonitorRadians[closestWrapPixel]; + } + + var spacingRadians = lengthToRadianFn(monitorSpacingPixels); + if (closestWrapPixel !== monitorBeginPixel) { + var gapPixels = monitorBeginPixel - closestWrapPixel; + var gapRadians = lengthToRadianFn(gapPixels); + var appliedSpacingRadians = Math.floor(gapPixels / monitorLengthPixels) * spacingRadians; + closestWrap = closestWrap + gapRadians + appliedSpacingRadians; + closestWrapPixel = monitorBeginPixel; + cachedMonitorRadians[closestWrapPixel] = closestWrap; + } + + var monitorRadians = lengthToRadianFn(monitorLengthPixels); + var centerRadians = closestWrap + monitorRadians / 2; + var endRadians = closestWrap + monitorRadians; + + var nextMonitorPixel = monitorBeginPixel + monitorLengthPixels; + if (cachedMonitorRadians[nextMonitorPixel] === undefined) + cachedMonitorRadians[nextMonitorPixel] = endRadians + spacingRadians; + + return { + begin: closestWrap, + center: centerRadians, + end: endRadians + } + } + + function horizontalMonitorSort(monitors) { + return monitors.map(function(monitor, index) { + return { originalIndex: index, monitorDetails: monitor }; + }).sort(function(a, b) { + var aMon = a.monitorDetails; + var bMon = b.monitorDetails; + if (aMon.y !== bMon.y) return aMon.y - bMon.y; + return aMon.x - bMon.x; + }); + } + + function verticalMonitorSort(monitors) { + return monitors.map(function(monitor, index) { + return { originalIndex: index, monitorDetails: monitor }; + }).sort(function(a, b) { + var aMon = a.monitorDetails; + var bMon = b.monitorDetails; + if (aMon.x !== bMon.x) return aMon.x - bMon.x; + return aMon.y - bMon.y; + }); + } + + // fovDetails: { widthPixels, heightPixels, defaultDistanceHorizontalRadians, defaultDistanceVerticalRadians, completeScreenDistancePixels, monitorWrappingScheme, curvedDisplay } + // monitorDetailsList: [{x, y, width, height}, ...] + // monitorSpacing: number (percentage, e.g. 0.05 for 5%) + function monitorsToPlacements(fovDetails, monitorDetailsList, monitorSpacing) { + var monitorPlacements = []; + var cachedMonitorRadians = {}; + + var conversionFns = fovDetails.curvedDisplay ? fovConversionFns.curved : fovConversionFns.flat; + + if (fovDetails.monitorWrappingScheme === 'horizontal') { + var sideEdgeRadius = conversionFns.centerToFovEdgeDistance(fovDetails.completeScreenDistancePixels, fovDetails.widthPixels); + var monitorSpacingPixels = monitorSpacing * fovDetails.widthPixels; + var lengthToRadianFn = function(targetWidth) { + return conversionFns.lengthToRadians( + fovDetails.defaultDistanceHorizontalRadians, + fovDetails.widthPixels, + sideEdgeRadius, + targetWidth + ); + }; + + cachedMonitorRadians[0] = -fovDetails.defaultDistanceHorizontalRadians / 2; + horizontalMonitorSort(monitorDetailsList).forEach(function(obj) { + var monitorDetails = obj.monitorDetails; + var originalIndex = obj.originalIndex; + var monitorWrapDetails = monitorWrap(cachedMonitorRadians, monitorSpacingPixels, monitorDetails.x, monitorDetails.width, lengthToRadianFn); + var monitorCenterRadius = conversionFns.fovEdgeToScreenCenterDistance(sideEdgeRadius, monitorDetails.width); + var upTopPixels = -monitorDetails.y - (monitorDetails.y / fovDetails.heightPixels) * monitorSpacingPixels; + var upCenterOffsetPixels = (monitorDetails.height - fovDetails.heightPixels) / 2; + var upCenterPixels = upTopPixels - upCenterOffsetPixels; + + monitorPlacements.push({ + originalIndex: originalIndex, + centerNoRotate: Qt.vector3d( + monitorCenterRadius, + 0, + upCenterPixels + ), + centerLook: Qt.vector3d( + monitorCenterRadius * Math.cos(monitorWrapDetails.center), + -monitorCenterRadius * Math.sin(monitorWrapDetails.center), + upCenterPixels + ).normalized(), + rotationAngleRadians: { + x: 0, + y: -monitorWrapDetails.center + } + }); + }); + } else if (fovDetails.monitorWrappingScheme === 'vertical') { + var topEdgeRadius = conversionFns.centerToFovEdgeDistance(fovDetails.completeScreenDistancePixels, fovDetails.heightPixels); + var monitorSpacingPixels = monitorSpacing * fovDetails.heightPixels; + var lengthToRadianFn = function(targetHeight) { + return conversionFns.lengthToRadians( + fovDetails.defaultDistanceVerticalRadians, + fovDetails.heightPixels, + topEdgeRadius, + targetHeight + ); + }; + + cachedMonitorRadians[0] = -fovDetails.defaultDistanceVerticalRadians / 2; + verticalMonitorSort(monitorDetailsList).forEach(function(obj) { + var monitorDetails = obj.monitorDetails; + var originalIndex = obj.originalIndex; + var monitorWrapDetails = monitorWrap(cachedMonitorRadians, monitorSpacingPixels, monitorDetails.y, monitorDetails.height, lengthToRadianFn); + var monitorCenterRadius = conversionFns.fovEdgeToScreenCenterDistance(topEdgeRadius, monitorDetails.height); + var westLeftPixels = -monitorDetails.x - (monitorDetails.x / fovDetails.widthPixels) * monitorSpacingPixels; + var westCenterOffsetPixels = (monitorDetails.width - fovDetails.widthPixels) / 2; + var westCenterPixels = westLeftPixels - westCenterOffsetPixels; + + monitorPlacements.push({ + originalIndex: originalIndex, + centerNoRotate: Qt.vector3d( + monitorCenterRadius, + westCenterPixels, + 0 + ), + centerLook: Qt.vector3d( + monitorCenterRadius * Math.cos(monitorWrapDetails.center), + westCenterPixels, + -monitorCenterRadius * Math.sin(monitorWrapDetails.center) + ).normalized(), + rotationAngleRadians: { + x: -monitorWrapDetails.center, + y: 0 + } + }); + }); + } else { + var monitorSpacingPixels = monitorSpacing * fovDetails.widthPixels; + monitorDetailsList.forEach(function(monitorDetails, index) { + var upTopPixels = -monitorDetails.y - (monitorDetails.y / fovDetails.heightPixels) * monitorSpacingPixels; + var westLeftPixels = -monitorDetails.x - (monitorDetails.x / fovDetails.widthPixels) * monitorSpacingPixels; + var westCenterOffsetPixels = (monitorDetails.width - fovDetails.widthPixels) / 2; + var upCenterOffsetPixels = (monitorDetails.height - fovDetails.heightPixels) / 2; + var westCenterPixels = westLeftPixels - westCenterOffsetPixels; + var upCenterPixels = upTopPixels - upCenterOffsetPixels; + + monitorPlacements.push({ + originalIndex: index, + centerNoRotate: Qt.vector3d( + fovDetails.completeScreenDistancePixels, + westCenterPixels, + upCenterPixels + ), + centerLook: Qt.vector3d( + fovDetails.completeScreenDistancePixels, + westCenterPixels, + upCenterPixels + ).normalized(), + rotationAngleRadians: { + x: 0, + y: 0 + } + }); + }); + } + + // put them back in the original monitor order before returning + monitorPlacements.sort(function(a, b) { return a.originalIndex - b.originalIndex; }); + + return monitorPlacements; + } + + // returns how far the look vector is from the center of the monitor, as a percentage of the monitor's dimensions + function getMonitorDistance(fovDetails, lookUpPixels, lookWestPixels, monitorVector, monitorDetails, upAngleToLength, westAngleToLength) { + var vectorUpPixels = upAngleToLength( + fovDetails.defaultDistanceVerticalRadians, + fovDetails.heightPixels, + fovDetails.completeScreenDistancePixels, + monitorVector.z, + monitorVector.x + ); + var upPercentage = Math.abs(lookUpPixels - vectorUpPixels) / monitorDetails.height; + + var vectorWestPixels = westAngleToLength( + fovDetails.defaultDistanceHorizontalRadians, + fovDetails.widthPixels, + fovDetails.completeScreenDistancePixels, + monitorVector.y, + monitorVector.x + ); + var westPercentage = Math.abs(lookWestPixels - vectorWestPixels) / monitorDetails.width; + + // how close we are to any edge is the largest of the two percentages + return Math.max(upPercentage, westPercentage); + } + + function findFocusedMonitor(quaternion, monitorVectors, currentFocusedIndex, smoothFollowEnabled, fovDetails, monitorsDetails) { + var lookVector = Qt.vector3d(1.0, 0.0, 0.0); // NWU vector pointing to the center of the screen + var rotatedLookVector = quaternion.times(lookVector); + + // Use curved or flat conversion functions depending on wrapping scheme + var upConversionFns = fovDetails.monitorWrappingScheme === "vertical" ? fovConversionFns.curved : fovConversionFns.flat; + var lookUpPixels = upConversionFns.angleToLength( + fovDetails.defaultDistanceVerticalRadians, + fovDetails.heightPixels, + fovDetails.completeScreenDistancePixels, + rotatedLookVector.z, + rotatedLookVector.x + ); + var westConversionFns = fovDetails.monitorWrappingScheme === "horizontal" ? fovConversionFns.curved : fovConversionFns.flat; + var lookWestPixels = westConversionFns.angleToLength( + fovDetails.defaultDistanceHorizontalRadians, + fovDetails.widthPixels, + fovDetails.completeScreenDistancePixels, + rotatedLookVector.y, + rotatedLookVector.x + ); + + // Check current focused monitor first + if (currentFocusedIndex !== -1) { + var focusedDistance = getMonitorDistance( + fovDetails, + lookUpPixels, + lookWestPixels, + monitorVectors[currentFocusedIndex], + monitorsDetails[currentFocusedIndex], + upConversionFns.angleToLength, + westConversionFns.angleToLength + ) * effect.focusedDisplayDistance / effect.allDisplaysDistance; + + if (smoothFollowEnabled || focusedDistance < unfocusThreshold) + return currentFocusedIndex; + } + + var closestIndex = -1; + var closestDistance = Number.POSITIVE_INFINITY; + + // Find the closest monitor + for (var i = 0; i < monitorVectors.length; ++i) { + if (i === currentFocusedIndex) + continue; + var distance = getMonitorDistance( + fovDetails, + lookUpPixels, + lookWestPixels, + monitorVectors[i], + monitorsDetails[i], + upConversionFns.angleToLength, + westConversionFns.angleToLength + ); + + if (distance < closestDistance) { + closestIndex = i; + closestDistance = distance; + } + } + + if (smoothFollowEnabled || closestDistance < focusThreshold) + return closestIndex; + + // Unfocus all displays + return -1; + } +} \ No newline at end of file diff --git a/kwin/src/qml/SingleDesktopView.qml b/kwin/src/qml/SingleDesktopView.qml new file mode 100644 index 0000000..88eeccd --- /dev/null +++ b/kwin/src/qml/SingleDesktopView.qml @@ -0,0 +1,53 @@ +import QtQuick + +Item { + id: singleDesktopView + property point cursorPos: effect.cursorPos + property bool supportsXR: false + property bool showCalibratingBanner: false + + function cursorInBounds() { + const x = cursorPos.x + const y = cursorPos.y + const screenGeom = targetScreen.geometry + return x >= screenGeom.x && + x < screenGeom.x + screenGeom.width && + y >= screenGeom.y && + y < screenGeom.y + screenGeom.height + } + + DesktopView { + id: desktopViewComponent + screen: targetScreen + width: targetScreen.geometry.width + height: targetScreen.geometry.height + } + + Image { + id: cursorImg + x: 0 + y: 0 + z: 9999 // ensure on top + } + + Image { + source: effect.customBannerEnabled ? "custom_banner.png" : "calibrating.png" + visible: supportsXR && showCalibratingBanner + anchors.horizontalCenter: desktopViewComponent.horizontalCenter + anchors.bottom: desktopViewComponent.bottom + } + + onCursorPosChanged: { + if (singleDesktopView.cursorInBounds()) { + const newX = effect.cursorPos.x - targetScreen.geometry.x + const newY = effect.cursorPos.y - targetScreen.geometry.y + const newSrc = effect.cursorImageSource + if (cursorImg.x !== newX) cursorImg.x = newX + if (cursorImg.y !== newY) cursorImg.y = newY + if (cursorImg.source !== newSrc) cursorImg.source = newSrc + if (!cursorImg.visible) cursorImg.visible = true + } else if (cursorImg.visible) { + cursorImg.visible = false + } + } +} \ No newline at end of file diff --git a/kwin/src/qml/cursorOverlay.frag b/kwin/src/qml/cursorOverlay.frag new file mode 100644 index 0000000..4660e60 --- /dev/null +++ b/kwin/src/qml/cursorOverlay.frag @@ -0,0 +1,18 @@ +VARYING vec3 pos; +VARYING vec2 texcoord; + +void MAIN() { + vec2 tex = vec2(texcoord.x, 1.0 - texcoord.y); + vec4 color = texture(desktopTex, tex); + if (showCursor) { + vec2 fragCoord = tex * vec2(screenWidth, screenHeight); + vec2 cursorTopLeft = vec2(cursorX, cursorY); + vec2 cursorBottomRight = cursorTopLeft + vec2(cursorW, cursorH); + if (fragCoord.x >= cursorTopLeft.x && fragCoord.x < cursorBottomRight.x && fragCoord.y >= cursorTopLeft.y && fragCoord.y < cursorBottomRight.y) { + vec2 rel = (fragCoord - cursorTopLeft) / vec2(cursorW, cursorH); + vec4 cursorCol = texture(cursorTex, rel); + color = mix(color, cursorCol, cursorCol.a); + } + } + FRAGCOLOR = color; +} \ No newline at end of file diff --git a/kwin/src/qml/cursorOverlay.vert b/kwin/src/qml/cursorOverlay.vert new file mode 100644 index 0000000..4dc6bf3 --- /dev/null +++ b/kwin/src/qml/cursorOverlay.vert @@ -0,0 +1,10 @@ +VARYING vec3 pos; +VARYING vec2 texcoord; + +// this is a no-op vertex shader, CustomMaterial required one +void MAIN() +{ + pos = VERTEX; + texcoord = UV0; + POSITION = MODELVIEWPROJECTION_MATRIX * vec4(pos, 1.0); +} \ No newline at end of file diff --git a/kwin/src/qml/main.qml b/kwin/src/qml/main.qml new file mode 100644 index 0000000..d99b850 --- /dev/null +++ b/kwin/src/qml/main.qml @@ -0,0 +1,151 @@ +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 Max 2", + "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, + effect.allDisplaysDistance, + effect.displayWrappingScheme + ) + + property var monitorPlacements: { + const dx = effect.displayHorizontalOffset * viewportResolution[0]; + const dy = effect.displayVerticalOffset * viewportResolution[1]; + const adjustedGeometries = screens.map(screen => { + const g = screen.geometry; + return { + x: g.x - screensXMid + dx, + y: g.y - screensYMid + dy, + width: g.width, + height: g.height + }; + }); + return displays.monitorsToPlacements(fovDetails, adjustedGeometries, effect.displaySpacing); + } + + property bool targetScreenSupported: supportedModels.some(model => root.targetScreen.model.includes(model)) + property bool imuResetState: effect.imuResetState + property bool isEnabled: effect.isEnabled + + Component { + id: desktopViewComponent + SingleDesktopView { + supportsXR: targetScreenSupported + showCalibratingBanner: isEnabled && imuResetState + } + } + + Component { + id: view3DComponent + View3D { + anchors.fill: parent + environment: SceneEnvironment { + antialiasingMode: root.effect.antialiasingQuality === 0 ? SceneEnvironment.NoAA : SceneEnvironment.SSAA + antialiasingQuality: root.effect.antialiasingQuality === 0 ? SceneEnvironment.Medium : ( + root.effect.antialiasingQuality === 1 ? SceneEnvironment.Medium : ( + root.effect.antialiasingQuality === 2 ? SceneEnvironment.High : SceneEnvironment.VeryHigh)) + } + + PerspectiveCamera { + id: camera + } + + BreezyDesktop { + id: breezyDesktop + } + + CameraController { + id: cameraController + anchors.fill: parent + camera: camera + } + } + } + + Loader { + id: viewLoader + anchors.fill: parent + } + + function checkLoadedComponent() { + console.log(`Breezy - checking screen ${targetScreen.model}: ${targetScreenSupported} ${isEnabled} ${imuResetState}`); + const show3DView = targetScreenSupported && isEnabled && !imuResetState; + viewLoader.sourceComponent = show3DView ? view3DComponent : desktopViewComponent; + } + + onImuResetStateChanged: { + checkLoadedComponent(); + } + + onIsEnabledChanged: { + checkLoadedComponent(); + } + + Component.onCompleted: { + checkLoadedComponent(); + } +} diff --git a/kwin/src/xrdriveripc/CMakeLists.txt b/kwin/src/xrdriveripc/CMakeLists.txt new file mode 100644 index 0000000..81887b6 --- /dev/null +++ b/kwin/src/xrdriveripc/CMakeLists.txt @@ -0,0 +1,26 @@ +add_library(xr_driver_ipc STATIC + xrdriveripc.cpp +) + +# Ensure position independent code so the static archive can link into the KWin effect plugin (a shared module) +set_target_properties(xr_driver_ipc PROPERTIES POSITION_INDEPENDENT_CODE ON) + +# Generate an export header so symbols can be visible outside the shared lib +include(GenerateExportHeader) +generate_export_header(xr_driver_ipc EXPORT_FILE_NAME xr_driver_ipc_export.h) + +target_include_directories(xr_driver_ipc + PUBLIC + ${CMAKE_CURRENT_BINARY_DIR} # for generated export header +) + +target_compile_options(xr_driver_ipc PRIVATE + $<$:/EHsc> + $<$>:-fexceptions> +) + +target_link_libraries(xr_driver_ipc + PRIVATE Qt6::Core +) + +install(FILES xrdriveripc.py xrdriveripc_runner.py DESTINATION ${KDE_INSTALL_DATADIR}/kwin/effects/breezy_desktop) \ No newline at end of file diff --git a/kwin/src/xrdriveripc/xrdriveripc.cpp b/kwin/src/xrdriveripc/xrdriveripc.cpp new file mode 100644 index 0000000..4508f3d --- /dev/null +++ b/kwin/src/xrdriveripc/xrdriveripc.cpp @@ -0,0 +1,111 @@ +// New implementation using QProcess to call python +#include "xrdriveripc.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +XRDriverIPC &XRDriverIPC::instance() { + static XRDriverIPC inst; + if (!inst.m_initialized) { + QString installedFile = QStandardPaths::locate( + QStandardPaths::GenericDataLocation, + QStringLiteral("kwin/effects/breezy_desktop/xrdriveripc.py"), + QStandardPaths::LocateFile); + if (installedFile.isEmpty()) { + throw std::runtime_error("Cannot locate kwin/effects/breezy_desktop/xrdriveripc.py"); + } + inst.m_pythonDir = QFileInfo(installedFile).path(); + inst.m_initialized = true; + } + return inst; +} + +std::string XRDriverIPC::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(); +} + +std::optional XRDriverIPC::retrieveConfig() { + QByteArray out = invokePython(QStringLiteral("retrieve_config"), {}, QStringLiteral("0")); + 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 doc.object(); +} + +std::optional XRDriverIPC::retrieveDriverState() { + QByteArray out = invokePython(QStringLiteral("retrieve_driver_state"), {}, {}); + if (out.isEmpty()) return std::nullopt; + QJsonParseError err; auto doc = QJsonDocument::fromJson(out, &err); + if (err.error != QJsonParseError::NoError || !doc.isObject()) return std::nullopt; + return doc.object(); +} + +bool XRDriverIPC::writeConfig(const QJsonObject &configUpdate) { + QByteArray payload = QJsonDocument(configUpdate).toJson(QJsonDocument::Compact); + QByteArray out = invokePython(QStringLiteral("write_config"), payload, {}); + return !out.isEmpty(); +} + +bool XRDriverIPC::writeControlFlags(const std::map &flags) { + QJsonObject obj; for (const auto &kv : flags) obj.insert(QString::fromStdString(kv.first), kv.second); + QByteArray payload = QJsonDocument(obj).toJson(QJsonDocument::Compact); + QByteArray out = invokePython(QStringLiteral("write_control_flags"), payload, {}); + return !out.isEmpty(); +} + +bool XRDriverIPC::requestToken(const std::string &email) { + QByteArray out = invokePython(QStringLiteral("request_token"), {}, QString::fromStdString(email)); + if (out.isEmpty()) return false; + QString result = QString::fromUtf8(out).trimmed().toLower(); + return result == QStringLiteral("true"); +} + +bool XRDriverIPC::verifyToken(const std::string &token) { + QByteArray out = invokePython(QStringLiteral("verify_token"), {}, QString::fromStdString(token)); + if (out.isEmpty()) return false; + QString result = QString::fromUtf8(out).trimmed().toLower(); + return result == QStringLiteral("true"); +} diff --git a/kwin/src/xrdriveripc/xrdriveripc.h b/kwin/src/xrdriveripc/xrdriveripc.h new file mode 100644 index 0000000..ee6ca81 --- /dev/null +++ b/kwin/src/xrdriveripc/xrdriveripc.h @@ -0,0 +1,103 @@ +// C++ bridge now invoking xrdriveripc via external python process +#pragma once + +#include +#include +#include +#include + +// Export header generated by CMake (GenerateExportHeader) +#ifdef __has_include +# if __has_include("xr_driver_ipc_export.h") +# include "xr_driver_ipc_export.h" +# endif +#endif + +#ifndef XR_DRIVER_IPC_EXPORT +# define XR_DRIVER_IPC_EXPORT __attribute__((visibility("default"))) +#endif + +namespace XRStateEntry { + inline constexpr const char *Heartbeat = "heartbeat"; + inline constexpr const char *HardwareId = "hardware_id"; + inline constexpr const char *ConnectedDeviceBrand = "connected_device_brand"; + inline constexpr const char *ConnectedDeviceModel = "connected_device_model"; + inline constexpr const char *MagnetSupported = "magnet_supported"; + inline constexpr const char *MagnetCalibrationType = "magnet_calibration_type"; + inline constexpr const char *UsingMagnet = "using_magnet"; + inline constexpr const char *MagnetStale = "magnet_stale"; + inline constexpr const char *MagnetCalibrating = "magnet_calibrating"; + inline constexpr const char *GyroCalibrating = "gyro_calibrating"; + inline constexpr const char *AccelCalibrating = "accel_calibrating"; + inline constexpr const char *SbsModeEnabled = "sbs_mode_enabled"; + inline constexpr const char *SbsModeSupported = "sbs_mode_supported"; + inline constexpr const char *FirmwareUpdateRecommended = "firmware_update_recommended"; + inline constexpr const char *BreezyDesktopSmoothFollowEnabled = "breezy_desktop_smooth_follow_enabled"; + inline constexpr const char *IsGamescopeReshadeIPCConnected = "is_gamescope_reshade_ipc_connected"; + inline constexpr const char *UiView = "ui_view_enabled"; + + namespace UIView { + inline constexpr const char *DriverRunning = "driver_running"; + + namespace License { + inline constexpr const char *Tiers = "tiers"; + inline constexpr const char *Features = "features"; + inline constexpr const char *HardwareId = "hardware_id"; + inline constexpr const char *ConfirmedToken = "confirmed_token"; + inline constexpr const char *ActionNeeded = "action_needed"; + inline constexpr const char *EnabledFeatures = "enabled_features"; + } + } +} + +namespace XRConfigEntry { + inline constexpr const char *Disabled = "disabled"; + inline constexpr const char *GamescopeReshadeWaylandDisabled = "gamescope_reshade_wayland_disabled"; + inline constexpr const char *OutputMode = "output_mode"; + inline constexpr const char *ExternalMode = "external_mode"; + inline constexpr const char *MouseSensitivity = "mouse_sensitivity"; + inline constexpr const char *DisplayZoom = "display_zoom"; + inline constexpr const char *LookAhead = "look_ahead"; + inline constexpr const char *SbsDisplaySize = "sbs_display_size"; + inline constexpr const char *SbsDisplayDistance = "sbs_display_distance"; + inline constexpr const char *SbsContent = "sbs_content"; + inline constexpr const char *SbsModeStretched = "sbs_mode_stretched"; + inline constexpr const char *SideviewPosition = "sideview_position"; + inline constexpr const char *SideviewDisplaySize = "sideview_display_size"; + inline constexpr const char *VirtualDisplaySmoothFollowEnabled = "virtual_display_smooth_follow_enabled"; + inline constexpr const char *SideviewSmoothFollowEnabled = "sideview_smooth_follow_enabled"; + inline constexpr const char *SideviewFollowThreshold = "sideview_follow_threshold"; + inline constexpr const char *CurvedDisplay = "curved_display"; + inline constexpr const char *MultiTapEnabled = "multi_tap_enabled"; + inline constexpr const char *SmoothFollowTrackRoll = "smooth_follow_track_roll"; + inline constexpr const char *SmoothFollowTrackPitch = "smooth_follow_track_pitch"; + inline constexpr const char *SmoothFollowTrackYaw = "smooth_follow_track_yaw"; + inline constexpr const char *Debug = "debug"; +} + +class XR_DRIVER_IPC_EXPORT XRDriverIPC { +public: + static XRDriverIPC &instance(); + + std::optional retrieveConfig(); + std::optional retrieveDriverState(); + bool writeConfig(const QJsonObject &configUpdate); + bool writeControlFlags(const std::map &flags); + bool requestToken(const std::string &email); + bool verifyToken(const std::string &token); + + +private: + XRDriverIPC() = default; + ~XRDriverIPC() = default; + XRDriverIPC(const XRDriverIPC&) = delete; + XRDriverIPC& operator=(const XRDriverIPC&) = delete; + + std::string configHome() const; + QByteArray invokePython(const QString &method, + const QByteArray &payloadJson, + const QString &singleArg) const; + + bool m_initialized = false; + QString m_pythonDir; // directory containing xrdriveripc.py +}; diff --git a/kwin/src/xrdriveripc/xrdriveripc_runner.py b/kwin/src/xrdriveripc/xrdriveripc_runner.py new file mode 100644 index 0000000..3a47250 --- /dev/null +++ b/kwin/src/xrdriveripc/xrdriveripc_runner.py @@ -0,0 +1,70 @@ +#!/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 + +class Logger: + def info(self, *args, **kwargs): + pass + + def error(self, *args, **kwargs): + pass + + +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(logger=Logger(), 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()) diff --git a/modules/XRLinuxDriver b/modules/XRLinuxDriver index caca100..09c6627 160000 --- a/modules/XRLinuxDriver +++ b/modules/XRLinuxDriver @@ -1 +1 @@ -Subproject commit caca100752be8d5dec5e9d742bf5d2ae3e56b0fb +Subproject commit 09c6627d2a60985a4f8dde86be70d0e797d62f45 diff --git a/ui/modules/PyXRLinuxDriverIPC b/ui/modules/PyXRLinuxDriverIPC index cfe4c91..7fecfc6 160000 --- a/ui/modules/PyXRLinuxDriverIPC +++ b/ui/modules/PyXRLinuxDriverIPC @@ -1 +1 @@ -Subproject commit cfe4c918e2d23b56bf77de62d0d469cc4e5a7c2c +Subproject commit 7fecfc604b553b9155837c58aaae423ba00afc63 diff --git a/ui/src/licensefeaturerow.py b/ui/src/licensefeaturerow.py index 68ed4ac..02f2c52 100644 --- a/ui/src/licensefeaturerow.py +++ b/ui/src/licensefeaturerow.py @@ -26,12 +26,9 @@ class LicenseFeatureRow(Adw.ActionRow): self.set_subtitle(f"{status}{details}") def _feature_name(self, feature): - print(f"Translating feature: {feature}") - print(f"_ is: {_}") feature_names = { - 'sbs': lambda: gettext.gettext('Side-by-side mode (gaming)'), + 'sbs': lambda: _('Side-by-side mode (gaming)'), 'smooth_follow': lambda: _('Smooth Follow (gaming)'), 'productivity_basic': lambda: _('Breezy Desktop (productivity)') } - print(f"Translated string: {feature_names[feature]()}") return feature_names[feature]() \ No newline at end of file