diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..871c592e --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,9 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + labels: + - dependencies + - github-actions diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 6d0706c1..20c08b59 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -2,282 +2,89 @@ name: ci-build on: push: - branches: - - "**" + branches: [develop, master] pull_request: - branches: - - master - - develop - - 'feature/**' + branches: [develop, master] + workflow_dispatch: -jobs: - build: - strategy: - matrix: - docker_tag: - - archlinux - - fedora-31 - - fedora-32 - - fedora-33 - - fedora-34 - - fedora-37 - - fedora-38 - - debian-stretch - - debian-buster - - debian-bullseye - - debian-bookworm - - ubuntu-18.04 - - ubuntu-20.04 - - ubuntu-20.10 - - ubuntu-21.04 - - ubuntu-22.04 - - ubuntu-23.04 - - opensuse-15.0 - - opensuse-15.1 - - opensuse-15.2 - - opensuse-15.3 - - opensuse-15.4 - - opensuse-15.5 - - centos-8 - os: - - ubuntu-latest - - runs-on: ${{ matrix.os }} - env: - DOCKER_IMG: ghcr.io/jahnf/projecteur/projecteur - DOCKER_TAG: ${{ matrix.docker_tag }} - MAKEFLAGS: -j2 - CLOUDSMITH_USER: jahnf - CLOUDSMITH_SUMMARY: ci-build from branch '${{ github.ref }}' - CLOUDSMITH_DESC: For more information visit https://github.com/jahnf/Projecteur - - steps: - # =================================================================================== - # ---------- Add ~/.local/bin to PATH ---------- - - run: | - export LOCAL_BIN=~/.local/bin - echo "${LOCAL_BIN}" >> $GITHUB_PATH - - # =================================================================================== - # ---------- Checkout and build inside docker container ---------- - - uses: actions/checkout@v3 - with: - # unfortunately, currently we need all the history for a valid auto generated version - fetch-depth: 0 - - - run: | - export BRANCH=${GITHUB_REF/refs\/heads\//} - echo Detected branch: ${BRANCH} - echo "BRANCH=${BRANCH}" >> $GITHUB_ENV - - - name: Pull ${{ matrix.docker_tag }} docker image - run: | - docker pull ${DOCKER_IMG}:${{ matrix.docker_tag }} - - name: docker create build container - run: | - docker run --name build --env MAKEFLAGS=${MAKEFLAGS} \ - --env TRAVIS_BRANCH=${BRANCH} \ - -d -v `pwd`:/source:ro -t ${DOCKER_IMG}:${{ matrix.docker_tag }} - - name: cmake configuration - run: docker exec build /bin/bash -c "mkdir -p /build/dist-pkg && cd /build && cmake /source" - - name: cmake build - run: docker exec build /bin/bash -c "cd /build && cmake --build ." - - name: create linux package - run: docker exec build /bin/bash -c "cd /build && cmake --build . --target dist-package" - - name: Run projecteur executable, print version - run: | - docker exec build /bin/bash -c "cd /build && ./projecteur --version" - docker exec build /bin/bash -c "cd /build && ./projecteur -f" - - # =================================================================================== - # ---------- Gather artifacts and version information from container build ---------- - - name: Get created artifacts from docker container - run: | - docker cp build:/build/dist-pkg . - docker cp build:/build/version-string . - - - name: Set version environment variable - run: | - projecteur_version=`cat version-string` - echo "projecteur_version=${projecteur_version}" >> $GITHUB_ENV - - - name: Move source package - if: startsWith(matrix.docker_tag, 'archlinux') - run: mkdir -p source-pkg && mv dist-pkg/*source.tar.gz ./source-pkg || true - - - name: Get source package filename for artifact uploads - run: | - src_pkg_artifact=`ls -1 source-pkg/* | head -n 1` - echo "src_pkg_artifact=${src_pkg_artifact}" >> $GITHUB_ENV - - - name: Get binary package filename for artifact uploads - run: | - dist_pkg_artifact=`ls -1 dist-pkg/* | head -n 1` - echo "dist_pkg_artifact=${dist_pkg_artifact}" >> $GITHUB_ENV - - - if: startsWith(matrix.docker_tag, 'archlinux') - run: echo "${{ env.BRANCH }}" >> version-branch - - # =================================================================================== - # ---------- Upload artifacts to github ---------- - - name: Upload source-pkg artifact to github - if: startsWith(matrix.docker_tag, 'archlinux') - uses: actions/upload-artifact@v3 - with: - name: source-package - path: ${{ env.src_pkg_artifact }} +concurrency: + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true - - name: Upload version-info to github - if: startsWith(matrix.docker_tag, 'archlinux') - uses: actions/upload-artifact@v3 - with: - name: version-info - path: | - ./version-string - ./version-branch +permissions: + contents: read - - name: Upload binary package artifact to github - uses: actions/upload-artifact@v3 - with: - name: ${{ matrix.docker_tag }}-package - path: ${{ env.dist_pkg_artifact }} +env: + LC_ALL: C.UTF-8 + # GitVersion predates GitHub Actions and uses this value for detached checkouts. + TRAVIS_BRANCH: ${{ github.base_ref || github.ref_name }} - # =================================================================================== - # ---------- Set environment variables depending on branch ---------- - - name: Set environment variable defaults - run: | - echo "upload_bin_pkg=${{ false }}" >> $GITHUB_ENV - echo "upload_src_pkg=${{ false }}" >> $GITHUB_ENV - echo "cloudsmith_upload_repo=projecteur-develop" >> $GITHUB_ENV - echo "REPO_UPLOAD=${{ false }}" >> $GITHUB_ENV - - - name: Check for binary-pkg upload conditions - if: ${{ (env.BRANCH == 'develop' || env.BRANCH == 'master') && github.repository == 'jahnf/Projecteur' }} - run: | - echo "upload_bin_pkg=${{ true }}" >> $GITHUB_ENV - pip install --upgrade wheel - pip install --upgrade cloudsmith-cli - - - name: Check for source-pkg upload conditions - if: ${{ env.upload_bin_pkg == 'true' && startsWith(matrix.docker_tag, 'archlinux') && github.repository == 'jahnf/Projecteur' }} - run: | - echo "upload_src_pkg=${{ true }}" >> $GITHUB_ENV - - - if: env.BRANCH == 'master' - run: | - echo "cloudsmith_upload_repo=projecteur-stable" >> $GITHUB_ENV - - # =================================================================================== - # ---------- Upload artifacts to cloudsmith ---------- - - name: Upload raw binary-pkg to cloudsmith - if: env.upload_bin_pkg == 'true' - env: - CLOUDSMITH_API_KEY: ${{ secrets.CLOUDSMITH_API_KEY }} - CLOUDSMITH_REPO: ${{ env.cloudsmith_upload_repo }} - run: | - cloudsmith push raw -W -k ${CLOUDSMITH_API_KEY} --name ${{ matrix.docker_tag }} --republish \ - --version ${{ env.projecteur_version }} ${CLOUDSMITH_USER}/${CLOUDSMITH_REPO} \ - --summary "${CLOUDSMITH_SUMMARY}" --description "${CLOUDSMITH_DESC}" ${{ env.dist_pkg_artifact }} - - - name: Upload raw source-pkg to cloudsmith - if: ${{ env.upload_src_pkg == 'true' && github.repository == 'jahnf/Projecteur' }} - env: - CLOUDSMITH_API_KEY: ${{ secrets.CLOUDSMITH_API_KEY }} - CLOUDSMITH_REPO: ${{ env.cloudsmith_upload_repo }} - run: | - cloudsmith push raw -W -k ${CLOUDSMITH_API_KEY} --name sources --republish \ - --version ${{ env.projecteur_version }} ${CLOUDSMITH_USER}/${CLOUDSMITH_REPO} \ - --summary "${CLOUDSMITH_SUMMARY}" --description "${CLOUDSMITH_DESC}" ${{ env.src_pkg_artifact }} - - - name: Get package and repo type for upload - if: env.upload_bin_pkg == 'true' - run: | - filename=$(basename -- "${{ env.dist_pkg_artifact }}") - export PKG_TYPE="${filename##*.}" - declare -A distromap=( ["debian-stretch"]="debian/stretch" ["debian-buster"]="debian/buster" \ - ["debian-bullseye"]="debian/bullseye" ["debian-bookworm"]="debian/bookworm" \ - ["ubuntu-18.04"]="ubuntu/bionic" \ - ["ubuntu-20.04"]="ubuntu/focal" ["ubuntu-21.04"]="ubuntu/hirsute" \ - ["ubuntu-22.04"]="ubuntu/jammy" ["ubuntu-23.04"]="ubuntu/lunar" \ - ["opensuse-15.1"]="opensuse/15.1" ["opensuse-15.2"]="opensuse/15.2" \ - ["opensuse-15.3"]="opensuse/15.3" ["opensuse-15.4"]="opensuse/15.4" \ - ["opensuse-15.5"]="opensuse/15.5" ["centos-8"]="el/8" \ - ["fedora-31"]="fedora/31" \ - ["fedora-32"]="fedora/32" ["fedora-33"]="fedora/33" \ - ["fedora-34"]="fedora/34" ["fedora-37"]="fedora/37" ["fedora-38"]="fedora/38" ) - export DISTRO=${distromap[${{ matrix.docker_tag }}]} - echo PKGTYPE=$PKG_TYPE - echo DISTRO=$DISTRO - echo "PKG_TYPE=${PKG_TYPE}" >> $GITHUB_ENV - echo "DISTRO=${DISTRO}" >> $GITHUB_ENV - if [ -z ${DISTRO} ] || [ -z ${PKG_TYPE} ]; then \ - export REPO_UPLOAD=false; else export REPO_UPLOAD=true; fi; - echo "REPO_UPLOAD=${REPO_UPLOAD}" >> $GITHUB_ENV - - - name: Linux repo upload on cloudsmith for ${{ env.DISTRO }} - if: env.REPO_UPLOAD == 'true' - env: - CLOUDSMITH_API_KEY: ${{ secrets.CLOUDSMITH_API_KEY }} - CLOUDSMITH_REPO: ${{ env.cloudsmith_upload_repo }} - run: | - echo Uploading for ${DISTRO} - ${PKG_TYPE}: ${CLOUDSMITH_USER}/${CLOUDSMITH_REPO}/${DISTRO} - cloudsmith push ${PKG_TYPE} -W -k ${CLOUDSMITH_API_KEY} --republish \ - ${CLOUDSMITH_USER}/${CLOUDSMITH_REPO}/${DISTRO} ${{ env.dist_pkg_artifact }} - - # ===================================================================================== - # ---------- Upload artifacts to projecteur server ------------ - projecteur-bin-upload: - if: ${{ github.repository == 'jahnf/Projecteur' }} - needs: build +jobs: + build: + name: ${{ matrix.id }} runs-on: ubuntu-latest + continue-on-error: ${{ matrix.moving && github.base_ref != 'master' && github.ref_name != 'master' }} + container: + image: ${{ matrix.image }} + strategy: + fail-fast: false + matrix: + include: + - id: archlinux + image: archlinux:latest + bootstrap: pacman -Sy --noconfirm git + moving: false + package: true + - id: fedora-44 + image: fedora:44 + bootstrap: dnf -y -q install git + moving: false + package: true + - id: tumbleweed + image: opensuse/tumbleweed:latest + bootstrap: zypper -qn refresh && zypper -qn install -y git-core gawk + moving: true + package: true + - id: debian-testing + image: debian:testing-slim + bootstrap: apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq git + moving: true + package: true + - id: ubuntu-26.10 + image: ubuntu:devel + bootstrap: apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq git + moving: true + package: true + - id: fedora-rawhide + image: fedora:rawhide + bootstrap: dnf -y -q install git + moving: true + package: false + - id: debian-sid + image: debian:sid-slim + bootstrap: apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq git + moving: true + package: false steps: - - name: Get version-info - uses: actions/download-artifact@v3 - with: - name: version-info - - - name: Extract version info - run: | - BRANCH=`cat version-branch` - echo "BRANCH=${BRANCH}" >> $GITHUB_ENV - VERSION=`cat version-string` - echo "VERSION=${VERSION}" >> $GITHUB_ENV - DO_UPLOAD=$(( [ "master" = "$BRANCH" ] || [ "develop" = "$BRANCH" ] ) && echo true || echo false) - echo "DO_UPLOAD=${DO_UPLOAD}" >> $GITHUB_ENV - - - uses: actions/download-artifact@v3 - if: env.DO_UPLOAD == 'true' - with: - path: artifacts - - - name: Create upload directory - if: env.DO_UPLOAD == 'true' - run: | - BRANCHDIR=${{ env.BRANCH }} - [ "master" = "$BRANCHDIR" ] && BRANCHDIR=stable - VERSION=${{ env.VERSION }} - mkdir -p upload/$BRANCHDIR/$VERSION - find ./artifacts -iname "projecteur*" -exec mv -t upload/$BRANCHDIR/$VERSION {} + - BRANCHNAME=${BRANCHDIR/\//_} - BRANCH_FILENAME=${BRANCHNAME}-latest.json - echo '{ "version": "${{ env.VERSION}}" }' >> upload/$BRANCH_FILENAME - echo "BRANCHNAME=${BRANCHNAME}" >> $GITHUB_ENV - find . -iname "projecteur*" - cd upload/$BRANCHDIR/$VERSION - sha1sum * > sha1sums.txt - - - name: 📂 Upload files - if: env.DO_UPLOAD == 'true' - run: | - cd upload && sudo apt-get install lftp --no-install-recommends - lftp ${{ secrets.PROJECTEUR_UPLOAD_HOSTNAME }} \ - -u "${{ secrets.PROJECTEUR_UPLOAD_USER }},${{ secrets.PROJECTEUR_UPLOAD_TOKEN }}" \ - -e "set ftp:ssl-force true; set ssl:verify-certificate true; mirror \ - --reverse --upload-older --dereference -x ^\.git/$ ./ ./; quit" - - - name: Update latest symlink - if: env.DO_UPLOAD == 'true' - run: | - curl --fail -i -X POST -F "token=${{ secrets.PROJECTEUR_UPDATE_TOKEN }}" \ - ${{ secrets.PROJECTEUR_UPDATE_URL }}?branch=${{ env.BRANCHNAME }} + - name: Bootstrap Git for checkout + run: ${{ matrix.bootstrap }} + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + - name: Install dependencies + run: ./ci/install-dependencies.sh "${{ matrix.id }}" + + - name: Build, package, and smoke test + run: ./ci/build-target.sh "${{ matrix.id }}" + + - name: Preserve package for one day + if: matrix.package + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: package-${{ matrix.id }} + path: release-assets/${{ matrix.id }}/ + if-no-files-found: error + retention-days: 1 diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml deleted file mode 100644 index 388ee417..00000000 --- a/.github/workflows/codeql-analysis.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: "CodeQL" - -on: - push: - branches: - - "**" - pull_request: - branches: [develop, master] - -jobs: - analyse: - if: ${{ github.repository == 'jahnf/Projecteur' }} - name: Analyse - runs-on: ubuntu-20.04 - - steps: - - name: Install dependencies - run: | - sudo apt-get update && \ - sudo apt-get --no-install-recommends install pkg-config qtdeclarative5-dev \ - qttools5-dev-tools qttools5-dev \ - qt5-default libqt5x11extras5-dev - - - name: Checkout repository - uses: actions/checkout@v3 - with: - # We must fetch at least the immediate parents so that if this is - # a pull request then we can checkout the head. - fetch-depth: 2 - - # If this run was triggered by a pull request event, then checkout - # the head of the pull request instead of the merge commit. - # - run: git checkout HEAD^2 - # if: ${{ github.event_name == 'pull_request' }} - - - name: Configure and build Qt moc cpps - run: | - mkdir build && cd build - cmake .. - make projecteur_autogen - make projecteur_autogen/mocs_compilation.cpp.o - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v2 - with: - queries: +security-and-quality - - - name: Build project - run: | - cd build - make -j2 - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..173fdde4 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,159 @@ +name: release + +on: + push: + tags: ['v*'] + workflow_dispatch: + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +env: + LC_ALL: C.UTF-8 + TRAVIS_BRANCH: master + +jobs: + validate: + name: validate-release-ref + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + - name: Validate version and release ancestry + shell: bash + run: | + set -euo pipefail + version="$( SHA256SUMS + + - name: Attest release assets + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: release-assets/* + + - name: Preserve rehearsal bundle for one day + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-bundle + path: release-assets/ + if-no-files-found: error + retention-days: 1 + + - name: Create immutable GitHub release + if: github.event_name == 'push' + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + tag="${GITHUB_REF_NAME}" + gh release create "$tag" --repo "$GITHUB_REPOSITORY" --verify-tag \ + --draft --generate-notes --title "Projecteur ${tag#v}" + gh release upload "$tag" release-assets/* --repo "$GITHUB_REPOSITORY" + gh release edit "$tag" --repo "$GITHUB_REPOSITORY" --draft=false --latest diff --git a/.gitignore b/.gitignore index 0535854a..dbd0ab0b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ CMakeLists.txt.user* .vscode .idea *.code-workspace -build-* -build/* +/build-* +/build/* +/release-assets/ icons/icon-font/output/ diff --git a/55-projecteur.rules.in b/55-projecteur.rules.in index af461319..16dad014 100644 --- a/55-projecteur.rules.in +++ b/55-projecteur.rules.in @@ -7,14 +7,21 @@ # Rule for the Logitech Spotlight USB Receiver SUBSYSTEMS=="usb", ATTRS{idVendor}=="046d", ATTRS{idProduct}=="c53e", MODE="0660", TAG+="uaccess" +# Rule for the Logitech Spotlight 2 Logi Bolt USB-C Receiver +SUBSYSTEMS=="usb", ATTRS{idVendor}=="046d", ATTRS{idProduct}=="c548", MODE="0660", TAG+="uaccess" + # Additional supported USB devices @EXTRA_USB_UDEV_RULES@ -# Rule fot the Logitech Spotlight when connected via Bluetooth +# Rule for the Logitech Spotlight when connected via Bluetooth # Updated rule, thanks to Torsten Maehne (https://github.com/maehne) SUBSYSTEMS=="input", ENV{LIBINPUT_DEVICE_GROUP}="5/46d/b503*", ATTRS{name}=="SPOTLIGHT*", MODE="0660", TAG+="uaccess" # Additional rule for Bluetooth sub-devices (hidraw) SUBSYSTEMS=="hid", KERNELS=="0005:046D:B503.*", MODE="0660", TAG+="uaccess" +# Rules for the Logitech Spotlight 2 when connected via Bluetooth +SUBSYSTEM=="input", ATTRS{id/vendor}=="046d", ATTRS{id/product}=="b506", MODE="0660", TAG+="uaccess" +SUBSYSTEMS=="hid", KERNELS=="0005:046D:B506.*", MODE="0660", TAG+="uaccess" + # Additional supported Bluetooth devices @EXTRA_BLUETOOTH_UDEV_RULES@ # Rules for uinput: Essential for creating a virtual input device that diff --git a/CMakeLists.txt b/CMakeLists.txt index 6d1691ff..ee9a6631 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,13 +1,4 @@ -cmake_minimum_required(VERSION 3.6) - -# Use QTDIR environment variable with find_package, -# e.g. set QTDIR=/home/user/Qt/5.9.6/gcc_64/ -if(NOT "$ENV{QTDIR}" STREQUAL "") - set(QTDIR $ENV{QTDIR}) - list(APPEND CMAKE_PREFIX_PATH $ENV{QTDIR}) -elseif(QTDIR) - list(APPEND CMAKE_PREFIX_PATH ${QTDIR}) -endif() +cmake_minimum_required(VERSION 3.20) # Set the default build type to release if( NOT CMAKE_BUILD_TYPE ) @@ -17,147 +8,180 @@ endif() set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS "Debug" "Release" "MinSizeRel" "RelWithDebInfo") -project(Projecteur LANGUAGES CXX) +file(STRINGS "${CMAKE_CURRENT_SOURCE_DIR}/VERSION" PROJECTEUR_RELEASE_VERSION + LIMIT_COUNT 1 REGEX "^[0-9]+\\.[0-9]+\\.[0-9]+$") +if(NOT PROJECTEUR_RELEASE_VERSION) + message(FATAL_ERROR "VERSION must contain one semantic version such as 1.0.0") +endif() + +project(Projecteur VERSION "${PROJECTEUR_RELEASE_VERSION}" LANGUAGES C CXX) add_compile_options(-Wall -Wextra -Werror) #set(CMAKE_CXX_CLANG_TIDY clang-tidy-12) +find_package(ECM 6.7 REQUIRED NO_MODULE) +list(APPEND CMAKE_MODULE_PATH ${ECM_MODULE_PATH}) +include(KDEInstallDirs6) +include(ECMQtDeclareLoggingCategory) +find_package(QtWaylandScanner REQUIRED) + list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake/modules") include(GitVersion) -include(Translation) - -set(QtVersionOptions "Auto" "5" "6") -set(PROJECTEUR_QT_VERSION "Auto" CACHE STRING "Choose the Qt version") -set_property(CACHE PROJECTEUR_QT_VERSION PROPERTY STRINGS ${QtVersionOptions}) - -list(FIND QtVersionOptions ${PROJECTEUR_QT_VERSION} index) -if(index EQUAL -1) - message(FATAL_ERROR "PROJECTEUR_QT_VERSION must be one of ${QtVersionOptions}") -endif() - -if ("${PROJECTEUR_QT_VERSION}" STREQUAL "Auto") - find_package(QT NAMES Qt6 Qt5 RCOMPONENTS Core REQUIRED) -else() - set(QT_VERSION_MAJOR ${PROJECTEUR_QT_VERSION}) -endif() - -find_package(Qt${QT_VERSION_MAJOR} COMPONENTS Core REQUIRED) -set(QT_PACKAGE_NAME Qt${QT_VERSION_MAJOR}) - -message(STATUS "Using Qt version: ${Qt${QT_VERSION_MAJOR}_VERSION}") - -if(${QT_PACKAGE_NAME}_VERSION VERSION_LESS "6.0") - set(CMAKE_CXX_STANDARD 14) -else() - set(CMAKE_CXX_STANDARD 17) -endif() +set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) - set(CMAKE_AUTOMOC ON) -find_package(${QT_PACKAGE_NAME} 5.7 REQUIRED COMPONENTS Core Gui Quick Widgets) - -if(${QT_PACKAGE_NAME}_VERSION VERSION_LESS "6.0") - find_package(${QT_PACKAGE_NAME} QUIET COMPONENTS X11Extras) - set(HAS_Qt_X11Extras ${${QT_PACKAGE_NAME}_FOUND}) -else() - set(HAS_Qt_X11Extras 0) -endif() - -find_package(${QT_PACKAGE_NAME} QUIET COMPONENTS DBus) -set(HAS_Qt_DBus ${${QT_PACKAGE_NAME}_FOUND}) -find_package(${QT_PACKAGE_NAME} QUIET COMPONENTS QuickCompiler) -set(HAS_Qt_QuickCompiler ${${QT_PACKAGE_NAME}_FOUND}) - -# Qt 5.8 seems to have issues with the way Projecteur shows the full screen overlay window, -# let's warn the user about it. -if(Qt5_VERSION VERSION_EQUAL "5.8" - OR (Qt5_VERSION VERSION_GREATER "5.8" AND Qt5_VERSION VERSION_LESS "5.9")) - message(WARNING "There are known issues when using Projecteur with Qt Version 5.8, " - "please use a different Qt Version.") -endif() - -if (HAS_Qt_QuickCompiler) - # Off by default, since this ties the application strictly to the Qt version - # it is built with, see https://doc.qt.io/qt-5.12/qtquick-deployment.html#compiling-qml-ahead-of-time - option(USE_QTQUICK_COMPILER "Use the QtQuickCompiler" OFF) -else() - set(USE_QTQUICK_COMPILER OFF) -endif() - -if (USE_QTQUICK_COMPILER) - message(STATUS "Using QtQuick Compiler.") - qtquick_compiler_add_resources(RESOURCES qml/qml.qrc) - # Avoid CMake policy CMP0071 warning - foreach(resfile IN LISTS RESOURCES) - set_property(SOURCE "${resfile}" PROPERTY SKIP_AUTOMOC ON) - endforeach() -else() - if(${QT_PACKAGE_NAME}_VERSION VERSION_LESS "6.0") - qt5_add_resources(RESOURCES qml/qml.qrc) - else() - qt6_add_resources(RESOURCES qml/qml-qt6.qrc) - endif() -endif() +find_package(Qt6 6.10 REQUIRED COMPONENTS Core DBus Gui Quick ShaderTools WaylandClient Widgets) +find_package(KF6 6.7 REQUIRED COMPONENTS Config ConfigWidgets CoreAddons DBusAddons GlobalAccel I18n + Notifications Package KirigamiPlatform WidgetsAddons WindowSystem XmlGui) +find_package(KPipeWire 6.7 REQUIRED) +find_package(Plasma 6.7 REQUIRED) +find_package(LayerShellQt 6.7 REQUIRED) +message(STATUS "Using Qt version: ${Qt6_VERSION}") -if(${QT_PACKAGE_NAME}_VERSION VERSION_LESS "6.0") - qt5_add_resources(RESOURCES resources.qrc) -else() - qt6_add_resources(RESOURCES resources.qrc) -endif() +qt6_add_resources(RESOURCES qml/qml.qrc resources.qrc) add_executable(projecteur src/main.cc src/enum-helper.h - src/aboutdlg.cc src/aboutdlg.h src/actiondelegate.cc src/actiondelegate.h - src/colorselector.cc src/colorselector.h src/device.cc src/device.h src/device-command-helper.cc src/device-command-helper.h src/device-hidpp.cc src/device-hidpp.h src/device-key-lookup.cc src/device-key-lookup.h - src/device-vibration.cc src/device-vibration.h src/deviceinput.cc src/deviceinput.h src/devicescan.cc src/devicescan.h src/deviceswidget.cc src/deviceswidget.h src/hidpp.cc src/hidpp.h src/linuxdesktop.cc src/linuxdesktop.h + src/kwinscreencast.cc src/kwinscreencast.h src/iconwidgets.cc src/iconwidgets.h src/imageitem.cc src/imageitem.h src/inputmapconfig.cc src/inputmapconfig.h src/inputseqedit.cc src/inputseqedit.h - src/logging.cc src/logging.h src/nativekeyseqedit.cc src/nativekeyseqedit.h src/preferencesdlg.cc src/preferencesdlg.h + src/presentationtimer.cc src/presentationtimer.h src/projecteurapp.cc src/projecteurapp.h - src/runguard.cc src/runguard.h + src/projecteurcontrol.cc src/projecteurcontrol.h src/settings.cc src/settings.h src/spotlight.cc src/spotlight.h src/spotshapes.cc src/spotshapes.h src/virtualdevice.cc src/virtualdevice.h ${RESOURCES}) -target_include_directories(projecteur PRIVATE src) +qt6_add_shaders(projecteur projecteur_shaders + PREFIX "/shaders" + BASE "${CMAKE_CURRENT_SOURCE_DIR}/qml/shaders" + FILES qml/shaders/textzoom.frag +) -target_link_libraries(projecteur - PRIVATE ${QT_PACKAGE_NAME}::Core ${QT_PACKAGE_NAME}::Quick ${QT_PACKAGE_NAME}::Widgets +ecm_add_qtwayland_client_protocol(projecteur + PROTOCOL "${CMAKE_CURRENT_SOURCE_DIR}/protocols/zkde-screencast-unstable-v1.xml" + BASENAME zkde-screencast-unstable-v1 ) -if(HAS_Qt_X11Extras) - if(${QT_PACKAGE_NAME}_VERSION VERSION_LESS "6.0") - target_link_libraries(projecteur PRIVATE ${QT_PACKAGE_NAME}::X11Extras) - endif() - target_compile_definitions(projecteur PRIVATE HAS_Qt_X11Extras=1) -else() - message(STATUS "Compiling without Qt5::X11Extras.") -endif() +ecm_qt_declare_logging_category(projecteur + HEADER projecteur_main_debug.h + IDENTIFIER PROJECTEUR_MAIN_LOG + CATEGORY_NAME projecteur.mainapp + DEFAULT_SEVERITY Info + DESCRIPTION "Projecteur application" + EXPORT Projecteur +) +ecm_qt_declare_logging_category(projecteur + HEADER projecteur_command_debug.h + IDENTIFIER PROJECTEUR_COMMAND_LOG + CATEGORY_NAME projecteur.cmdserver + DEFAULT_SEVERITY Info + DESCRIPTION "Projecteur command service" + EXPORT Projecteur +) +ecm_qt_declare_logging_category(projecteur + HEADER projecteur_desktop_debug.h + IDENTIFIER PROJECTEUR_DESKTOP_LOG + CATEGORY_NAME projecteur.desktop + DEFAULT_SEVERITY Info + DESCRIPTION "Projecteur desktop integration" + EXPORT Projecteur +) +ecm_qt_declare_logging_category(projecteur + HEADER projecteur_device_debug.h + IDENTIFIER PROJECTEUR_DEVICE_LOG + CATEGORY_NAME projecteur.device + DEFAULT_SEVERITY Info + DESCRIPTION "Projecteur device handling" + EXPORT Projecteur +) +ecm_qt_declare_logging_category(projecteur + HEADER projecteur_hid_debug.h + IDENTIFIER PROJECTEUR_HID_LOG + CATEGORY_NAME projecteur.hid + OLD_CATEGORY_NAMES projecteur.HID + DEFAULT_SEVERITY Info + DESCRIPTION "Projecteur HID++ protocol" + EXPORT Projecteur +) +ecm_qt_declare_logging_category(projecteur + HEADER projecteur_input_debug.h + IDENTIFIER PROJECTEUR_INPUT_LOG + CATEGORY_NAME projecteur.input + DEFAULT_SEVERITY Info + DESCRIPTION "Projecteur input mapping" + EXPORT Projecteur +) +ecm_qt_declare_logging_category(projecteur + HEADER projecteur_settings_debug.h + IDENTIFIER PROJECTEUR_SETTINGS_LOG + CATEGORY_NAME projecteur.settings + DEFAULT_SEVERITY Info + DESCRIPTION "Projecteur settings" + EXPORT Projecteur +) +ecm_qt_declare_logging_category(projecteur + HEADER projecteur_virtual_device_debug.h + IDENTIFIER PROJECTEUR_VIRTUAL_DEVICE_LOG + CATEGORY_NAME projecteur.virtualdevice + DEFAULT_SEVERITY Info + DESCRIPTION "Projecteur virtual input devices" + EXPORT Projecteur +) +ecm_qt_install_logging_categories( + EXPORT Projecteur + FILE projecteur.categories + DESTINATION "${KDE_INSTALL_LOGGINGCATEGORIESDIR}" + SORT +) -if(HAS_Qt_DBus) - target_link_libraries(projecteur PRIVATE ${QT_PACKAGE_NAME}::DBus) - target_compile_definitions(projecteur PRIVATE HAS_Qt_DBus=1) -else() - message(STATUS "Compiling without Qt5::DBus.") -endif() +set(PROJECTEUR_CONTROL_XML + "${CMAKE_CURRENT_SOURCE_DIR}/src/org.projecteur.Projecteur.xml") +qt_add_dbus_adaptor(PROJECTEUR_DBUS_ADAPTOR_SOURCES + "${PROJECTEUR_CONTROL_XML}" + "${CMAKE_CURRENT_SOURCE_DIR}/src/projecteurcontrol.h" + ProjecteurControl + projecteurcontroladaptor + ProjecteurControlAdaptor +) +target_sources(projecteur PRIVATE ${PROJECTEUR_DBUS_ADAPTOR_SOURCES}) + +kconfig_target_kcfg_file(projecteur + FILE "${CMAKE_CURRENT_SOURCE_DIR}/src/projecteurconfig.kcfg" + CLASS_NAME ProjecteurConfig + MUTATORS + DEFAULT_VALUE_GETTERS + KCONFIG_CONSTRUCTOR +) + +target_include_directories(projecteur PRIVATE src "${CMAKE_CURRENT_BINARY_DIR}") + +target_link_libraries(projecteur + PRIVATE Qt6::Core Qt6::DBus Qt6::Quick Qt6::WaylandClient Qt6::Widgets + K::KPipeWire + KF6::ConfigCore KF6::ConfigGui KF6::ConfigWidgets KF6::CoreAddons KF6::DBusAddons + KF6::GlobalAccel KF6::I18n KF6::Notifications KF6::WidgetsAddons + KF6::WindowSystem KF6::XmlGui + LayerShellQt::Interface +) target_compile_options(projecteur PRIVATE @@ -167,36 +191,41 @@ target_compile_options(projecteur target_compile_definitions(projecteur PRIVATE CXX_COMPILER_ID=${CMAKE_CXX_COMPILER_ID} CXX_COMPILER_VERSION=${CMAKE_CXX_COMPILER_VERSION}) +add_subdirectory(plasma) + # Set version project properties for builds not from a git repository (e.g. created with git archive) # If creating the version number via git information fails, the following target properties # will be used. IMPORTANT - when creating a release tag with git flow: # Update this information - the version numbers and the version type. # VERSION_TYPE must be either 'release' or 'develop' set_target_properties(projecteur PROPERTIES - VERSION_MAJOR 0 - VERSION_MINOR 10 - VERSION_PATCH 0 + VERSION_MAJOR "${PROJECT_VERSION_MAJOR}" + VERSION_MINOR "${PROJECT_VERSION_MINOR}" + VERSION_PATCH "${PROJECT_VERSION_PATCH}" + # Source archives do not contain .git. They must still report the release version. VERSION_TYPE release VERSION_DISTANCE_OFFSET 0 ) add_version_info(projecteur "${CMAKE_CURRENT_SOURCE_DIR}") # Create files containing generated version strings, helping package maintainers -get_target_property(PROJECTEUR_VERSION_STRING projecteur VERSION_STRING) +get_target_property(PROJECTEUR_VERSION_STRING projecteur VERSION_STRING_FULL) # Arch Linux = PKGBUILD/makepkg: '-' is not allowed in version number string(REPLACE "-" "" PROJECTEUR_VERSION_STRING_ARCHLINUX "${PROJECTEUR_VERSION_STRING}") file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/version-string" "${PROJECTEUR_VERSION_STRING}") file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/version-string.archlinux" "${PROJECTEUR_VERSION_STRING_ARCHLINUX}") -# Translation -list(APPEND languages de fr es) -set(ts_directories "${CMAKE_CURRENT_SOURCE_DIR}/i18n") -add_translations_target("projecteur" "${CMAKE_CURRENT_BINARY_DIR}" "${ts_directories}" "${languages}") -add_translation_update_task("projecteur" "${CMAKE_CURRENT_SOURCE_DIR}" "${CMAKE_CURRENT_SOURCE_DIR}/i18n" "${languages}") +# Install gettext catalogs supplied by KDE's translation infrastructure. +ki18n_install(po) # Add target with non-source files for convenience when using IDEs like QtCreator and others -add_custom_target(non-sources SOURCES README.md LICENSE.md doc/CHANGELOG.md devices.conf - src/extra-devices.cc.in 55-projecteur.rules.in +add_custom_target(non-sources SOURCES README.md CONTRIBUTING.md LICENSE.md + doc/CHANGELOG.md doc/USER-GUIDE.md + doc/TROUBLESHOOTING.md devices.conf + src/extra-devices.cc.in src/projecteurconfig.kcfg + src/org.projecteur.Projecteur.xml + qml/shaders/textzoom.frag + 55-projecteur.rules.in projecteur.notifyrc cmake/templates/projecteur.desktop.in) # Install @@ -281,14 +310,24 @@ install(FILES icons/projecteur-tray.svg DESTINATION share/icons/hicolor/48x48/ap install(FILES icons/projecteur-tray.svg DESTINATION share/icons/hicolor/64x64/apps/ RENAME projecteur.svg) install(FILES icons/projecteur-tray.svg DESTINATION share/icons/hicolor/128x128/apps/ RENAME projecteur.svg) install(FILES icons/projecteur-tray.svg DESTINATION share/icons/hicolor/256x256/apps/ RENAME projecteur.svg) +install(FILES projecteur.notifyrc DESTINATION ${KDE_INSTALL_KNOTIFYRCDIR}) +install(FILES src/org.projecteur.Projecteur.xml DESTINATION ${KDE_INSTALL_DBUSINTERFACEDIR}) # Set variables for file configurations get_target_property(VERSION_STRING projecteur VERSION_STRING) get_target_property(VERSION_DATE_MONTH_YEAR projecteur VERSION_DATE_MONTH_YEAR) -set(HOMEPAGE "https://github.com/jahnf/Projecteur") - -configure_file("${TMPLDIR}/projecteur.desktop.in" "projecteur.desktop" @ONLY) -install(FILES "${OUTDIR}/projecteur.desktop" DESTINATION share/applications/) +set(HOMEPAGE "https://github.com/gbin/Projecteur") + +set(PROJECTEUR_APPLICATION_ID "org.projecteur.Projecteur") +configure_file("${TMPLDIR}/projecteur.desktop.in" + "${PROJECTEUR_APPLICATION_ID}.desktop" @ONLY) +install(FILES "${OUTDIR}/${PROJECTEUR_APPLICATION_ID}.desktop" + DESTINATION share/applications/) +kdbusaddons_generate_dbus_service_file( + projecteur + "${PROJECTEUR_APPLICATION_ID}" + "${KDE_INSTALL_FULL_BINDIR}" +) # Configure man page and gzip it. option(COMPRESS_MAN_PAGE "Compress the man page" ON) @@ -307,8 +346,10 @@ else() install(FILES "${OUTDIR}/projecteur.1" DESTINATION share/man/man1/) endif() -configure_file("${TMPLDIR}/projecteur.metainfo.xml" "projecteur.metainfo.xml" @ONLY) -install(FILES "${OUTDIR}/projecteur.metainfo.xml" DESTINATION share/metainfo/) +configure_file("${TMPLDIR}/projecteur.metainfo.xml" + "${PROJECTEUR_APPLICATION_ID}.metainfo.xml" @ONLY) +install(FILES "${OUTDIR}/${PROJECTEUR_APPLICATION_ID}.metainfo.xml" + DESTINATION share/metainfo/) configure_file("${TMPLDIR}/projecteur.bash-completion" "projecteur.bash-completion" @ONLY) install(FILES "${OUTDIR}/projecteur.bash-completion" @@ -331,7 +372,7 @@ if(PACKAGE_TARGETS) add_dist_package_target( PROJECT "${CMAKE_PROJECT_NAME}" TARGET projecteur - DESCRIPTION_BRIEF "Linux/X11 application for the Logitech Spotlight device." + DESCRIPTION_BRIEF "Wayland application for the Logitech Spotlight device." DESCRIPTION_FULL "Projecteur is a virtual laser pointer for use with inertial pointers such as the Logitech Spotlight. Projecteur can show a colored dot, a highlighted @@ -373,4 +414,3 @@ find_program(iwyu_path NAMES include-what-you-use iwyu) if(ENABLE_IWYU AND iwyu_path) set_property(TARGET projecteur PROPERTY CXX_INCLUDE_WHAT_YOU_USE ${iwyu_path}) endif() - diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7545b51b..521eb0db 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,5 +1,155 @@ -# Contributing +# Contributing to Projecteur -* Contributions are very welcome. -* When contributing to this repository, please first discuss the change(s) you wish to implement - via issue, email, or any other method with the owners of this repository before making a change. +Thanks for helping make Linux presentations better. Bug fixes, device support, +documentation, translations, and focused feature improvements are all welcome. + +For a substantial change, please open an issue before investing heavily so the +approach and project scope can be agreed on. Small, self-contained fixes can go +straight to a pull request. + +## Project scope + +The current Projecteur development line targets: + +- KDE Plasma 6.7 or newer on Wayland +- Qt 6.10 or newer +- LayerShellQt and KPipeWire 6.7 or newer +- Linux presenter devices exposed through evdev and hidraw + +Qt 5, X11, and non-Plasma support are maintained only for critical fixes on the +[`legacy/qt5`](https://github.com/gbin/Projecteur/tree/legacy/qt5) branch. + +## Requirements + +- A C++17 compiler +- CMake 3.20 or newer +- Extra CMake Modules 6.7 or newer +- Qt 6.10 or newer with Core, DBus, Gui, Quick, ShaderTools, WaylandClient, and + Widgets +- KDE Frameworks 6.7 or newer: Config, ConfigWidgets, CoreAddons, DBusAddons, + GlobalAccel, I18n, Notifications, Package, KirigamiPlatform, WidgetsAddons, + WindowSystem, and XmlGui +- Plasma 6.7 or newer +- KPipeWire 6.7 or newer +- LayerShellQt 6.7 or newer +- gettext for translations + +Package names vary by distribution. The CI workflow and +[`Justfile`](./Justfile) are the canonical dependency lists for Arch Linux. + +## Build from source + +```sh +git clone https://github.com/gbin/Projecteur.git +cd Projecteur +cmake -S . -B build \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX=/usr \ + -DPACKAGE_TARGETS=OFF +cmake --build build --parallel +``` + +The binary in `build/projecteur` can exercise most of the application, but live +zoom requires a proper installation. KWin authorizes the restricted capture +interfaces by matching the executable to Projecteur's installed desktop +metadata. + +For a complete local install: + +```sh +sudo cmake --install build +sudo udevadm control --reload-rules +sudo udevadm trigger +``` + +The install also provides the Plasma applet, desktop entry, AppStream metadata, +udev rules, notifications, D-Bus service, shell completion, and manual page. + +## Arch Linux workflow + +On Arch Linux and Arch-based distributions, install `just` and use: + +```sh +just build +just package +just install +``` + +- `just build` installs missing dependencies and compiles Projecteur. +- `just package` packages the current working tree—including uncommitted + files—into `build/packages/`. +- `just install` builds and installs that package with `pacman`, then restarts + Plasma Shell and Projecteur. + +`sudo` is used only when dependencies or the finished package need to be +installed. + +## Verify a change + +At minimum, rebuild and run the same smoke checks as CI: + +```sh +cmake --build build --parallel +./build/projecteur --version +./build/projecteur --help +git diff --check +``` + +For UI or device changes, also test the relevant workflow manually on Plasma +Wayland. In the pull request, mention the Plasma and Qt versions, connection type, +and presenter model you tested. + +## Project map + +| Path | Purpose | +| --- | --- | +| `src/` | Application, device handling, settings, and QWidget UI | +| `qml/` | Wayland overlay and zoom shaders | +| `plasma/` | Native Plasma system tray applet | +| `protocols/` | Wayland protocol definitions used by the zoom pipeline | +| `cmake/` | Build, packaging, desktop, AppStream, and manual templates | +| `po/` | gettext translation catalogs | +| `doc/` | User documentation, screenshots, and changelog | +| `packaging/arch/` | Local Arch package recipe | + +## Adding a presenter + +Add compile-time device support to [`devices.conf`](./devices.conf) using: + +```text +vendorId, productId, [usb|bt], name +``` + +For example: + +```text +0x0abc, 0x1234, usb, Example Presenter +``` + +CMake uses this list to generate both device definitions and udev rules. If the +device needs special event decoding or HID++ behavior, changes in `src/` may also +be required. + +For quick experiments, Projecteur accepts +`--additional-device VENDOR:PRODUCT` at runtime. + +## Translations + +Projecteur uses KDE's KI18n/gettext system. Run `Messages.sh` through the standard +KDE translation tooling to update `projecteur.pot`. Catalogs placed at +`po//projecteur.po` are compiled and installed automatically. + +Keep user-visible strings translatable and avoid assembling sentences from +fragments. + +## Pull requests + +Keep each pull request focused. Include: + +- what changed and why; +- how it was verified; +- screenshots for visible UI changes; +- device IDs and connection type for hardware-specific changes. + +By contributing, you agree that your work is provided under the project's +[MIT License](./LICENSE.md). diff --git a/Justfile b/Justfile new file mode 100644 index 00000000..8c4ad640 --- /dev/null +++ b/Justfile @@ -0,0 +1,182 @@ +set shell := ["bash", "-euo", "pipefail", "-c"] + +project_root := justfile_directory() +build_dir := project_root / "build" +arch_dir := build_dir / "arch-package" +package_dir := build_dir / "packages" + +# Show the available developer commands. +default: + @just --list + +# Compile Projecteur. +build: _require-arch deps + cmake -S "{{ project_root }}" -B "{{ build_dir }}" \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX=/usr \ + -DPACKAGE_TARGETS=OFF + cmake --build "{{ build_dir }}" --parallel + +# Build an Arch Linux package from the current working tree. +package: _require-arch deps + #!/usr/bin/env bash + set -euo pipefail + + if (( EUID == 0 )); then + echo "error: makepkg must be run as a regular user, not root." >&2 + exit 1 + fi + + root="{{ project_root }}" + stage="{{ arch_dir }}" + packages="{{ package_dir }}" + + mkdir -p "$stage" "$packages" + install -m 0644 "$root/packaging/arch/PKGBUILD" "$stage/PKGBUILD" + + cmake -S "$root" -B "$stage/version-build" -DPACKAGE_TARGETS=OFF + cp "$stage/version-build/version-string.archlinux" "$stage/projecteur-pkgver" + + git -C "$root" ls-files --cached --others --exclude-standard -z \ + | while IFS= read -r -d '' path; do + if [[ -e "$root/$path" || -L "$root/$path" ]]; then + printf '%s\0' "$path" + fi + done \ + | tar -C "$root" --null --no-recursion --files-from=- \ + --transform='s,^,projecteur-local/,' \ + -czf "$stage/projecteur-local.tar.gz" + + ( + cd "$stage" + updpkgsums + BUILDDIR="$stage/work" \ + PKGDEST="$packages" \ + SRCDEST="$stage/sources" \ + makepkg --cleanbuild --clean --force --noconfirm --syncdeps + ) + + echo + echo "Package created:" + ( + cd "$stage" + PKGDEST="$packages" makepkg --packagelist + ) + +# Build, package, and install Projecteur through pacman. +install: _stop-projecteur build package + #!/usr/bin/env bash + set -euo pipefail + + stage="{{ arch_dir }}" + packages="{{ package_dir }}" + + package_file="$( + cd "$stage" + PKGDEST="$packages" makepkg --packagelist | head -n 1 + )" + + if [[ ! -f "$package_file" ]]; then + echo "error: expected package was not created: $package_file" >&2 + exit 1 + fi + + if (( EUID != 0 )); then + if ! command -v sudo >/dev/null 2>&1; then + echo "error: installing the package requires root or sudo." >&2 + exit 1 + fi + # Authenticate before installing the package. + sudo -v + fi + + if (( EUID == 0 )); then + pacman -U --noconfirm "$package_file" + else + sudo pacman -U --noconfirm "$package_file" + fi + + systemctl --user restart plasma-plasmashell.service + dbus-send --session --print-reply=literal \ + --dest=org.freedesktop.DBus \ + /org/freedesktop/DBus \ + org.freedesktop.DBus.StartServiceByName \ + string:org.projecteur.Projecteur \ + uint32:0 >/dev/null + +_stop-projecteur: + #!/usr/bin/env bash + if [[ -x /usr/bin/projecteur ]]; then + /usr/bin/projecteur --command quit >/dev/null 2>&1 || true + fi + +# Install the compiler and Projecteur build dependencies. +deps: _require-arch + #!/usr/bin/env bash + set -euo pipefail + + dependencies=( + base-devel + cmake + extra-cmake-modules + gettext + git + kconfig + kconfigwidgets + kcoreaddons + kdbusaddons + kglobalaccel + ki18n + kpipewire + knotifications + kwidgetsaddons + kwindowsystem + kxmlgui + layer-shell-qt + libplasma + libglvnd + pacman-contrib + qt6-base + qt6-declarative + qt6-shadertools + qt6-wayland + ) + + mapfile -t missing < <(pacman -T "${dependencies[@]}" || true) + if (( ${#missing[@]} == 0 )); then + echo "Arch build dependencies are already installed." + exit 0 + fi + + echo "Installing missing Arch build dependencies: ${missing[*]}" + if (( EUID == 0 )); then + pacman -S --needed --noconfirm "${missing[@]}" + elif command -v sudo >/dev/null 2>&1; then + sudo pacman -S --needed --noconfirm "${missing[@]}" + else + echo "error: installing build dependencies requires root or sudo." >&2 + exit 1 + fi + +_require-arch: + #!/usr/bin/env bash + set -euo pipefail + + if [[ ! -r /etc/os-release ]]; then + echo "error: /etc/os-release is unavailable; this workflow requires Arch Linux." >&2 + exit 1 + fi + + # shellcheck disable=SC1091 + source /etc/os-release + distro_ids=" ${ID:-} ${ID_LIKE:-} " + if [[ "$distro_ids" != *" arch "* ]]; then + echo "error: this workflow requires Arch Linux or an Arch-based distribution." >&2 + echo " detected: ${PRETTY_NAME:-unknown Linux distribution}" >&2 + exit 1 + fi + + if ! command -v pacman >/dev/null 2>&1; then + echo "error: pacman was not found; this does not look like a usable Arch system." >&2 + exit 1 + fi diff --git a/Messages.sh b/Messages.sh new file mode 100755 index 00000000..682dec87 --- /dev/null +++ b/Messages.sh @@ -0,0 +1,4 @@ +#! /usr/bin/env bash +# SPDX-License-Identifier: MIT + +$XGETTEXT src/*.{cc,h} -o "$podir/projecteur.pot" diff --git a/README.md b/README.md index 25367c50..55fc43dc 100644 --- a/README.md +++ b/README.md @@ -1,407 +1,168 @@ # Projecteur -develop: [![Build Status develop][gh-badge-dev]][gh-link-dev] -master: [![Build Status master][gh-badge-rel]][gh-link-rel] - -Linux/X11 application for the Logitech Spotlight device (and similar devices). \ -See **[Download](#download)** section for binary packages. - -[gh-badge-dev]: https://github.com/jahnf/Projecteur/workflows/ci-build/badge.svg?branch=develop -[gh-badge-rel]: https://github.com/jahnf/Projecteur/workflows/ci-build/badge.svg?branch=master -[gh-link-dev]: https://github.com/jahnf/Projecteur/actions?query=workflow%3Aci-build+branch%3Adevelop -[gh-link-rel]: https://github.com/jahnf/Projecteur/actions?query=workflow%3Aci-build+branch%3Amaster - -## Motivation - -I saw the Logitech Spotlight device in action at a conference and liked it immediately. -Unfortunately as in a lot of cases, software is only provided for Windows and Mac. -The device itself works just fine on Linux, but the cool spotlight feature is -only available using additional software. - -So here it is: a Linux application for the Logitech Spotlight. - -## Table of Contents - -- [Projecteur](#projecteur) - - [Motivation](#motivation) - - [Table of Contents](#table-of-contents) - - [Features](#features) - - [Screenshots](#screenshots) - - [Planned features](#planned-features) - - [Supported Environments](#supported-environments) - - [How it works](#how-it-works) - - [Button mapping](#button-mapping) - - [Hold Button Mapping for Logitech Spotlight](#hold-button-mapping-for-logitech-spotlight) - - [Download](#download) - - [Building](#building) - - [Requirements](#requirements) - - [Build Example](#build-example) - - [Installation/Running](#installationrunning) - - [Pre-requisites](#pre-requisites) - - [When building Projecteur yourself](#when-building-projecteur-yourself) - - [Application Menu](#application-menu) - - [Command Line Interface](#command-line-interface) - - [Scriptability](#scriptability) - - [Using Projecteur without a device](#using-projecteur-without-a-device) - - [Device Support](#device-support) - - [Compile Time](#compile-time) - - [Runtime](#runtime) - - [Troubleshooting](#troubleshooting) - - [Opaque Spotlight / No Transparency](#opaque-spotlight--no-transparency) - - [Missing System Tray](#missing-system-tray) - - [Zoom is not updated while spotlight is shown](#zoom-is-not-updated-while-spotlight-is-shown) - - [Wayland](#wayland) - - [Wayland Zoom](#wayland-zoom) - - [Device shows as not connected](#device-shows-as-not-connected) - - [Changelog](#changelog) - - [License](#license) - -## Features - -* Configurable desktop spotlight - * _shade color_, _opacity_, _cursor_, _border_, _center dot_ and different _shapes_ - * Zoom (magnifier) functionality -* Multiple screen support -* Support of devices beyond the Logitech Spotlight (see [Device Support](#device-support)) -* Button mapping: - * Map any button on the device to (almost) any keyboard combination. - * Switch between (cycle through) custom spotlight presets. - * Audio Volume / Horizontal and Vertical Scrolling (Logitech Spotlight). -* Vibration (Timer) Support for the Logitech Spotlight -* Usable without a presenter device (e.g. for online presentations) - -### Screenshots - -[](./doc/screenshot-settings.png) -[](./doc/screenshot-spot.png) -[](./doc/screenshot-button-mapping.png) -[](./doc/screenshot-traymenu.png) - -### Planned features - -* Support for more customizable button mapping actions. -* Support of more proprietary features of the Logitech Spotlight and other devices. - -## Supported Environments - -The application was mostly tested on Ubuntu 18.04, Ubuntu 20.04 (GNOME) and -OpenSuse 15 (GNOME) but should work on almost any Linux/X11 Desktop. In case -you are building the application yourself, make sure you have the correct udev -rules installed (see [pre-requisites section](#pre-requisites)). - -## How it works - -With a connection via the USB Dongle Receiver or via Bluetooth, the Logitech Spotlight -device will be detected by Linux as a HID device with mouse and keyboard events. -As mouse events, the device sends relative cursor movements and left button presses. -Acting as a keyboard, the device basically just sends left and right arrow key press -events when forward or back is pressed on the device. - -The mouse move events of the device are what we are mainly interested in. Since the device is -already detected as a mouse input device and able to move the cursor, we simply detect -if the Spotlight device is sending mouse move events. If it is sending mouse events, -we will 'turn on' the desktop spot (virtual laser). - -For more details: Have a look at the source code ;) - -### Button mapping - -Button mapping works by **grabbing** all device events of connected -devices and forwarding them to a virtual _'uinput'_ device if not configured -differently by the button mapping configuration. If a mapped configuration for -a button exists, _Projecteur_ will inject the mapped action instead. -(You can still disable device grabbing with the `--disable-uinput` command -line option - button mapping will be disabled then.) - -Input events from the presenter device can be mapped to different actions. -The _Key Sequence_ action is particularly powerful as it can emit any user-defined -keystroke. These keystrokes can invoke shortcut in presentation software -(or any other software) being used. Similarly, the _Cycle Preset_ action can be -used for cycling different spotlight presets. However, it should be noted that -presets are ordered alphabetically on program start. To retain a certain -order of your presets, you can prepend the preset name with a number. - -#### Hold Button Mapping for Logitech Spotlight - -Logitech Spotlight can send Hold event for Next and Back buttons as HID++ -messages. Using this device feature, this program provides three different -usage of the Next or Hold button. - -1. Button Tap -2. Long-Press Event -3. Button Hold and Move Event - -On the Input Mapper tab (Devices tab in Preferences dialog box), the first two -button usages (_i.e._ tap and long-press) can be mapped directly by tapping or -long pressing the relevant button. For mapping the third button usage (_i.e._ -Hold Move Event), please ensure that the device is active by pressing any button, -and then right click in first column (Input Sequence) for any entry and select -the relevant option. Additional mapped actions (e.g. _Vertical Scrolling_, -_Horizontal Scrolling_, or _Volume control_) can be selected for these hold -move events. - -Please note that in case when both Long-Press event and Hold Move events are -mapped for a particular button, both actions will executed if user hold the -button and move device. To avoid this situation, do not set both Long-Press -and Hold Move actions for the same button. - -## Download - -The latest binary packages for some Linux distributions are available for download on cloudsmith. -Currently binary packages for _Ubuntu_, _Debian_, _Fedora_, _OpenSuse_, _CentOS_ and -_Arch_ Linux are automatically built. For release version downloads you can also visit -the project's [github releases page](https://github.com/jahnf/Projecteur/releases). - -* **Latest release:** - * on cloudsmith: [![cloudsmith-rel-badge]][cloudsmith-rel-latest] - * on secondery server: [![projecteur-rel-badge]][projecteur-rel-dl] -* Latest development version: - * on cloudsmith: [![cloudsmith-dev-badge]][cloudsmith-dev-latest] - * on secondary server: [![projecteur-dev-badge]][projecteur-dev-dl] - -See also the **[list of Linux repositories](./doc/LinuxRepositories.md)** where _Projecteur_ -is available. - -[cloudsmith-rel-badge]: https://img.shields.io/badge/dynamic/json?color=blue&labelColor=12577e&logo=cloudsmith&label=Projecteur&prefix=v&query=%24.version&url=https%3A%2F%2Fprojecteur.de%2Fdownloads%2Fstable-latest.json -[cloudsmith-rel-latest]: https://cloudsmith.io/~jahnf/repos/projecteur-stable/packages/?q=format%3Araw+tag%3Alatest -[cloudsmith-dev-badge]: https://img.shields.io/badge/dynamic/json?color=blue&labelColor=12577e&logo=cloudsmith&label=Projecteur&prefix=v&query=%24.version&url=https%3A%2F%2Fprojecteur.de%2Fdownloads%2Fdevelop-latest.json -[cloudsmith-dev-latest]: https://cloudsmith.io/~jahnf/repos/projecteur-develop/packages/?q=format%3Araw+tag%3Alatest -[projecteur-rel-badge]: https://img.shields.io/badge/dynamic/json?color=blue&label=Projecteur&prefix=v&query=%24.version&url=https%3A%2F%2Fprojecteur.de%2Fdownloads%2Fstable-latest.json -[projecteur-dev-badge]: https://img.shields.io/badge/dynamic/json?color=blue&label=Projecteur&prefix=v&query=%24.version&url=https%3A%2F%2Fprojecteur.de%2Fdownloads%2Fdevelop-latest.json -[projecteur-dev-dl]: https://projecteur.de/downloads/develop/latest -[projecteur-rel-dl]: https://projecteur.de/downloads/stable/latest +**A virtual laser pointer and live magnifier built for KDE Plasma presentations.** -## Building - -### Requirements +[![Build status](https://github.com/gbin/Projecteur/actions/workflows/ci-build.yml/badge.svg?branch=develop)](https://github.com/gbin/Projecteur/actions/workflows/ci-build.yml?query=branch%3Adevelop) +![KDE Plasma 6.7+](https://img.shields.io/badge/KDE_Plasma-6.7%2B-1d99f3?logo=kde&logoColor=white) +![Wayland only](https://img.shields.io/badge/display-Wayland_only-5c6bc0) +[![MIT license](https://img.shields.io/badge/license-MIT-2ea44f)](./LICENSE.md) -* C++14 compiler -* CMake 3.6 or later -* Qt 5.7 and later +Projecteur turns a Logitech Spotlight 1/2 and another supported presenter into an +on-screen spotlight your audience can see in the room, in a screen share, and in +the recording. Point, magnify, change slides, run a timer, and keep everything +close at hand in Plasma. -### Build Example +[Projecteur highlighting and magnifying part of a presentation slide](./doc/screenshot-spot.png) -```sh - git clone https://github.com/jahnf/Projecteur - cd Projecteur - mkdir build && cd build - cmake .. - make -``` - -Building against other Qt versions, than the default one from your Linux distribution -can be done by setting the `QTDIR` variable during CMake configuration. - -Example: `QTDIR=/opt/Qt/5.9.6/gcc_64 cmake ..` - -## Installation/Running - -### Pre-requisites - -#### When building Projecteur yourself - -The input devices detected from the Spotlight device must be readable to the -user running the application. To make this easier there is a udev rule template -file in this repository: `55-projecteur.rules.in` - -* During the CMake run, the file `55-projecteur.rules` will be created from this template - in your **build directory**. Copy that generated file to `/lib/udev/rules.d/55-projecteur.rules` -* Most recent systems (using systemd) will automatically pick up the rule. - If not, run `sudo udevadm control --reload-rules` and `sudo udevadm trigger` - to load the rules without a reboot. -* After that, the input devices from the Logitech USB Receiver (but also the Bluetooth device) - in /dev/input should be readable/writable by you. - (See also about [device detection](#device-shows-as-not-connected)) -* When building against the Qt version that comes with your distribution's packages, - you might need to install some additional QML module packages. For example this - is the case for Ubuntu, where you need to install the packages - `qml-module-qtgraphicaleffects`, `qml-module-qtquick-window2`, `qml-modules-qtquick2` and - `qtdeclarative5-dev` to satisfy the application's run time dependencies. - -### Application Menu - -The application menu is accessible via the system tray icon. There you will find -the preferences and the menu entry to exit the application. If the system tray icon is missing, -see the [Troubleshooting](#missing-system-tray) section. - -### Command Line Interface - -Additional to the standard `--help` and `--version` options, there is an option to send -commands to a running instance of _Projecteur_ and the ability to set properties. - -```txt -Usage: projecteur [OPTION]... - - - -h, --help Show command line usage. - --help-all Show complete command line usage with all properties. - -v, --version Print application version. - -f, --fullversion Print extended version info. - --cfg FILE Set custom config file. - -d, --device-scan Print device-scan results. - -l, --log-level LEVEL Set log level (dbg,inf,wrn,err), default is 'inf'. - --show-dialog Show preferences dialog on start. - -m, --minimize-only Only allow minimizing the preferences dialog. - -D DEVICE Additional accepted device; DEVICE=vendorId:productId - -c COMMAND|PROPERTY Send command/property to a running instance. - - - spot=[on|off|toggle] Turn spotlight on/off or toggle. - spot.size.adjust=[+|-]N Increase or decrease spot size by N. - settings=[show|hide] Show/hide preferences dialog. - preset=NAME Set a preset. - quit Quit the running instance. -``` - -A complete list the properties that can be set via the command line, can be listed with the -`--help-all` option or can also be found on the man pagers with newer versions of -_Projecteur_ (`man projecteur`). - -### Scriptability - -_Projecteur_ allows you to set almost all aspects of the spotlight via the command line -for a running instance. - -Example: - -```bash -# Set showing the border to true -projecteur -c border=true -# Set the border color to red -projecteur -c border.color=#ff0000 -# Send a vibrate command to the device with -# intensity=128 and length=0 (only Logitech Spotlight) -projecteur -c vibrate=128,0 -``` - -While _Projecteur_ does not provide global keyboard shortcuts, command line options -can but utilized for that. For instance, if you like to use _Projecteur_ as a tool while sharing -your screen in a video call without additional presenter hardware, you can assign global -shortcuts in your window manager (e.g. GNOME) to run the commands `projecteur -c spot=on` -and `projecteur -c spot=off` or `projecteur -c spot=toggle`, and therefore -turning the spot on and off with a keyboard shortcut. - -A complete list the properties that can be set via the command line, can be -listed with the `--help-all` command line option. - -### Using Projecteur without a device +> [!NOTE] +> The current development line requires **KDE Plasma 6.7 or newer**, **Qt 6.10 +> or newer**, and a **Wayland session**. The previous Qt 5, X11, and +> cross-desktop codebase is maintained for critical fixes on the +> [`legacy/qt5`](https://github.com/gbin/Projecteur/tree/legacy/qt5) branch. -You can use _Projecteur_ for your online presentations and video conferences without a presenter -device. For this you can assign a global keyboard shortcut in your window manager -(e.g. KDE, GNOME...) to run the command `projecteur -c spot=toggle`. You will then be able to -turn the digital spot on and off with the assigned keyboard shortcut while sharing -your screen in an online presentation or call. +## Why Projecteur? -### Device Support +- **Visible everywhere.** Unlike a physical laser, the spotlight appears in + projectors, screen shares, and recordings. +- **Live magnification.** KWin and KPipeWire keep video, animation, and changing + content moving inside the zoom area. +- **Made for Plasma.** Native system tray controls, global shortcuts, + notifications, and multi-screen support feel at home on KDE. +- **Designed for presenting.** Save spotlight presets, remap presenter buttons, + control volume or scrolling, and use haptic timer alerts on compatible devices. +- **Useful without hardware.** Trigger the spotlight from a global shortcut for + online demos and video calls. -Besides the _Logitech Spotlight_, the following devices are currently supported out of the box: +## See it in action -* AVATTO H100 / August WP200 _(0c45:8101)_ -* August LP315 _(2312:863d)_ -* AVATTO i10 Pro _(2571:4109)_ -* August LP310 _(69a7:9803)_ -* Norwii Wireless Presenter _(3243:0122)_ +### Magnify the content -#### Compile Time +Choose smooth scaling for images, edge-enhanced **Text and UI** mode for +documents and application demos, or pixel-perfect scaling for source pixels. +The zoom mode is saved with each spotlight preset. -Besides the Logitech Spotlight, similar devices can be used and are supported. -Additional devices can be added to `devices.conf`. At CMake configuration time, -the project will be configured to support these devices and also create entries -for them in the generated udev-rule file. +[Projecteur magnifying text and interface content with a sharp green-bordered spotlight](./doc/screenshot-text-zoom.png) -#### Runtime +### Stay in control from the Plasma panel -_Projecteur_ will also accept devices as supported when added via the `-D` -command line option. +See connected presenters, test the spotlight, switch presets, start a +presentation timer, and open preferences without breaking your flow. -Example: `projecteur -D 04b3:310c` +[Projecteur Plasma applet showing a connected Logitech Spotlight 2 and quick controls](./doc/screenshot-traymenu.png) -This will enable devices within _Projecteur_ and the application will try to -connect to that device if it is detected. It is, however, up to the user to make -sure the device is accessible (via udev rules). +### Make the spotlight yours -### Troubleshooting +Tune the shape, shade, zoom, cursor, border, multi-screen behavior, and presets +with native KDE controls. -#### Opaque Spotlight / No Transparency +[Projecteur preferences with spotlight shape, shade, zoom, cursor, border, and preset controls](./doc/screenshot-settings.png) -To be able to show transparent windows, a **compositing manager** is necessary. If there is no -compositing manager running you will see the spotlight overlay as an opaque window. +## Install -* On **KDE** it might be necessary to turn on Desktop effects to allow transparent windows. -* Depending on your Linux Desktop and configuration there might not be a compositing manager - running by default. You can run `xcompmgr`, `compton` or others manually. - * Examples: `xcompmgr -c -t-6 -l-6 -o.1` or `xcompmgr -c` +Stable releases provide source, Arch Linux, Fedora, openSUSE Tumbleweed, +Debian testing, and Ubuntu packages on the +[GitHub Releases page](https://github.com/gbin/Projecteur/releases). The install +step is important: KWin grants zoom access using Projecteur's installed desktop +metadata, and the presenter needs the installed udev rules. -#### Missing System Tray +### Arch Linux and Arch-based distributions -_Projecteur_ was developed and tested on GNOME and KDE Desktop environments, but should -work on most other desktop environments. If the system tray with the _Application Menu_ -is not showing, commands can be send to the application to bring up the preferences -dialog, test the spotlight, quit the application or set spotlight properties. -See [Command Line Interface](#command-line-interface). There is also a command -line option (`-m`) to prevent the preferences dialog from hiding, allowing it -only to minimize - behaving more like a regular application window. +Install [`just`](https://github.com/casey/just), then use the included packaging +workflow. It installs missing build dependencies, creates a native package, and +installs it with `pacman`. -On some distributions that have a **GNOME Desktop** by default there is -**no system tray extensions** installed (_Fedora_ for example). You can install the -[KStatusNotifierItem/AppIndicator Support][appind-ext] or the [TopIcons Plus][topicon-ext] -GNOME extension to have a system tray that can show the _Projecteur_ tray icon -(and also from other applications like Dropbox or Skype). - -[appind-ext]: https://extensions.gnome.org/extension/615/appindicator-support/ -[topicon-ext]: https://extensions.gnome.org/extension/1031/topicons/ - -#### Zoom is not updated while spotlight is shown - -Zoom does not update while spotlight is shown due to how the zoom currently works. A screenshot is -taken shortly before the overlay window is shown, and then a magnified section is shown wherever -the mouse/spotlight is. -If the zoom would be updated while the overlay window is shown, the overlay window it self would -show up in the magnified section. That is a general problem that other magnifier tools also face, -although they get around the problem by showing the magnified content rectangle always in the -same position on the screen. - -#### Wayland - -While not developed with Wayland in mind, users reported _Projecteur_ works with -Wayland. If you experience problems, you can try to set the `QT_QPA_PLATFORM` environment -variable to `wayland`, example: - -```bash -user@ubuntu1904:~/Projecteur/build$ QT_QPA_PLATFORM=wayland ./projecteur -Using Wayland-EGL +```sh +sudo pacman -S --needed just +git clone https://github.com/gbin/Projecteur.git +cd Projecteur +just install ``` -#### Wayland Zoom - -On Wayland the Zoom feature is currently only implemented on KDE and GNOME. This is done with -the help of their respective DBus interfaces for screen capturing. On other environments with -Wayland, the zoom feature is not currently supported. - -#### Device shows as not connected +### Other distributions -If the device shows as not connected, there are some things you can do: +Install the [build dependencies](./CONTRIBUTING.md#requirements), then: -* Check for devices with _Projecteur_'s command line option `-d` or `--device-scan` option. - This will show you a list of all supported and detected devices and also if - they are readable/writable. If a detected device is not readable/writable, it is an indicator - that there is something wrong with the installed _udev_ rules. -* Manually on the shell: Check if the device is detected by the Linux system: Run - `cat /proc/bus/input/devices | grep -A 5 "Vendor=046d"` \ - This should show one or multiple spotlight devices (among other Logitech devices) - * Check that the corresponding `/dev/input/event??` device file is readable by you. \ - Example: `test -r /dev/input/event19 && echo "SUCCESS" || echo "NOT readable"` -* Make sure you don't have conflicting udev rules installed, e.g. first you installed - the udev rule yourself and later you used the automatically built Linux packages to - install _Projecteur_. - -## Changelog +```sh +git clone https://github.com/gbin/Projecteur.git +cd Projecteur +cmake -S . -B build \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX=/usr \ + -DPACKAGE_TARGETS=OFF +cmake --build build --parallel +sudo cmake --install build +sudo udevadm control --reload-rules +sudo udevadm trigger +``` -See [CHANGELOG.md](./doc/CHANGELOG.md) for a detailed changelog. +Reconnect the presenter after installing, then launch **Projecteur** from the +application menu. + +## Your first minute + +1. Open the Projecteur applet in the Plasma system tray. +2. Confirm that your presenter appears under **Connected presenters**. +3. Select **Test Spotlight** to try the current look. +4. Open **Preferences** to adjust the spotlight or map presenter buttons. +5. Optionally assign **Toggle Spotlight** under **Preferences → Shortcuts** for + keyboard-only use. + +The applet can also start a presentation timer immediately or on the next button +press. While it runs, the panel badge shows the remaining minutes; compatible +presenters vibrate when time expires. + +## Supported presenters + +| Presenter | Connection | Device ID | +| --- | --- | --- | +| Logitech Spotlight | USB receiver / Bluetooth | `046d:c53e` / `046d:b503` | +| Logitech Spotlight 2 | Logi Bolt USB-C receiver / Bluetooth | `046d:c548` / `046d:b506` | +| Lenovo ThinkPad X1 Presenter Mouse | USB / Bluetooth | `17ef:60d9` / `17ef:60db` | +| AVATTO H100 / August WP200 | USB | `0c45:8101` | +| August LP315 | USB | `2312:863d` | +| AVATTO i10 Pro | USB | `2571:4109` | +| August LP310 | USB | `69a7:9803` | +| Norwii Wireless Presenter | USB | `3243:0122` | +| Norwii N95s BLE Presenter | USB receiver / Bluetooth | `3243:0382` / `3243:03a2` | +| Kensington PowerPointer | USB | `1ea7:0002` | + +Projecteur can also accept an additional device at runtime with +`--additional-device VENDOR:PRODUCT`. See `projecteur --help` for details. + +## Need help? + +- **Presenter not detected?** Run `projecteur --device-scan`. A detected device + that is not readable or writable usually means the udev rules are missing or + stale. +- **Zoom not working?** Confirm that Projecteur is installed—not run only from + the build directory—and that the session is KDE Plasma on Wayland. +- **Applet missing?** Check that Projecteur is running and enabled in the Plasma + system tray configuration. + +The [troubleshooting guide](./doc/TROUBLESHOOTING.md) has detailed checks. If the +problem remains, [open an issue](https://github.com/gbin/Projecteur/issues) +with the output of `projecteur --fullversion` and `projecteur --device-scan`. + +## Documentation + +- [User guide](./doc/USER-GUIDE.md) — presets, zoom modes, button mapping, + shortcuts, timers, and device-free use +- [Troubleshooting](./doc/TROUBLESHOOTING.md) — display, zoom, device access, and + system tray diagnostics +- [Changelog](./doc/CHANGELOG.md) +- [Contributing and development setup](./CONTRIBUTING.md) +- Command-line reference: `man projecteur` + +## About Projecteur + +Projecteur was created by Jahn Fuchs and transferred to Guillaume Binet in 2026. +The current development line adds a native Plasma 6 experience, a +Wayland-native overlay and live zoom pipeline, and Logitech Spotlight 2 support. +Please report problems in the [Projecteur issue tracker](https://github.com/gbin/Projecteur/issues). ## License -Copyright 2018-2021 Jahn Fuchs +Projecteur is available under the [MIT License](./LICENSE.md). -This project is distributed under the [MIT License](https://opensource.org/licenses/MIT), -see [LICENSE.md](./LICENSE.md) for more information. +Copyright © 2018–2021 Jahn Fuchs. Current development copyright © 2026 +Guillaume Binet and Projecteur contributors. diff --git a/VERSION b/VERSION new file mode 100644 index 00000000..3eefcb9d --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.0.0 diff --git a/ci/build-target.sh b/ci/build-target.sh new file mode 100755 index 00000000..da2f0caa --- /dev/null +++ b/ci/build-target.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +set -euo pipefail + +target="${1:?usage: build-target.sh TARGET}" +build_dir="build/ci-${target}" +# Package managers require an absolute path for locally built packages. +asset_dir="$(pwd)/release-assets/${target}" + +# Container jobs run as root while actions/checkout creates the worktree as the +# runner user. Trust this exact checkout so versioning and source archives can +# inspect Git history in subsequent container steps. +git config --global --add safe.directory "$(pwd)" + +case "$target" in + fedora-rawhide|debian-sid) + package_target=0 + ;; + *) + package_target=1 + ;; +esac + +package_targets=OFF +if (( package_target )); then + package_targets=ON +fi +if [[ "$target" == "archlinux" ]]; then + package_targets=OFF +fi + +cmake -S . -B "$build_dir" \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX=/usr \ + -DCMAKE_INSTALL_UDEVRULESDIR=/usr/lib/udev/rules.d \ + -DPACKAGE_TARGETS="$package_targets" +cmake --build "$build_dir" --parallel 2 +"$build_dir/projecteur" --version +"$build_dir/projecteur" --help >/dev/null + +if (( ! package_target )); then + exit 0 +fi + +mkdir -p "$asset_dir" + +if [[ "$target" == "archlinux" ]]; then + package_output_dir=build/packages + if (( EUID == 0 )); then + # GitHub container jobs run as root, while makepkg deliberately refuses to. + build_user=projecteur-ci + if ! id "$build_user" >/dev/null 2>&1; then + useradd --system --create-home --user-group "$build_user" + fi + build_group="$(id -gn "$build_user")" + rootless_stage="$(pwd)/build/ci-arch-package" + package_output_dir="$(pwd)/build/ci-arch-packages" + install -d -o "$build_user" -g "$build_group" "$rootless_stage" "$package_output_dir" + runuser -u "$build_user" -- env \ + HOME="$(getent passwd "$build_user" | cut -d: -f6)" \ + GIT_CONFIG_COUNT=1 \ + GIT_CONFIG_KEY_0=safe.directory \ + GIT_CONFIG_VALUE_0="$(pwd)" \ + just arch_dir="$rootless_stage" package_dir="$package_output_dir" package + else + just package + fi + cp "$package_output_dir"/projecteur-[0-9]*.pkg.tar.zst "$asset_dir/" +else + cmake --build "$build_dir" --target dist-package + cp "$build_dir"/dist-pkg/* "$asset_dir/" + if [[ "$target" == "fedora-44" ]]; then + cmake --build "$build_dir" --target source-archive + cp "$build_dir"/dist-pkg/*_source.tar.gz "$asset_dir/" + fi +fi + +case "$target" in + archlinux) + pacman -U --noconfirm "$asset_dir"/projecteur-[0-9]*.pkg.tar.zst + ;; + fedora-44) + dnf -y -q install "$asset_dir"/*.rpm + ;; + tumbleweed) + zypper -qn --no-gpg-checks install -y "$asset_dir"/*.rpm + ;; + debian-testing|ubuntu-26.10) + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "$asset_dir"/*.deb + ;; +esac + +/usr/bin/projecteur --version +find "$asset_dir" -maxdepth 1 -type f -printf '%f\n' | sort diff --git a/ci/install-dependencies.sh b/ci/install-dependencies.sh new file mode 100755 index 00000000..e0387a22 --- /dev/null +++ b/ci/install-dependencies.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +set -euo pipefail + +target="${1:?usage: install-dependencies.sh TARGET}" + +case "$target" in + archlinux) + pacman -Syu --noconfirm --needed \ + base-devel cmake extra-cmake-modules gettext git just pacman-contrib \ + kconfig kconfigwidgets kcoreaddons kdbusaddons kglobalaccel ki18n \ + knotifications kpipewire kwidgetsaddons kwindowsystem kxmlgui \ + layer-shell-qt libplasma libglvnd qt6-base qt6-declarative \ + qt6-shadertools qt6-wayland + ;; + fedora-44|fedora-rawhide) + dnf -y -q install \ + gcc-c++ cmake extra-cmake-modules gettext git pkgconf-pkg-config \ + systemd-devel rpm-build qt6-qtbase-devel qt6-qtdeclarative-devel \ + qt6-qtshadertools-devel qt6-qtwayland-devel kf6-kconfig-devel \ + kf6-kconfigwidgets-devel kf6-kcoreaddons-devel kf6-kdbusaddons-devel \ + kf6-kglobalaccel-devel kf6-ki18n-devel kf6-knotifications-devel \ + kf6-kpackage-devel kf6-kirigami-devel kf6-kwidgetsaddons-devel \ + kf6-kwindowsystem-devel kf6-kxmlgui-devel kpipewire-devel \ + libplasma-devel layer-shell-qt-devel + ;; + tumbleweed) + zypper -qn refresh + zypper -qn install -y \ + gcc-c++ cmake extra-cmake-modules gettext-tools git-core gawk \ + pkgconf-pkg-config libudev-devel rpm-build qt6-base-devel \ + qt6-declarative-devel qt6-shadertools-devel qt6-wayland-devel \ + kf6-kconfig-devel kf6-kconfigwidgets-devel kf6-kcoreaddons-devel \ + kf6-kdbusaddons-devel kf6-kglobalaccel-devel kf6-ki18n-devel \ + kf6-knotifications-devel kf6-kpackage-devel kf6-kirigami-devel \ + kf6-kwidgetsaddons-devel kf6-kwindowsystem-devel kf6-kxmlgui-devel \ + kpipewire6-devel libplasma6-devel layer-shell-qt6-devel + ;; + debian-testing|debian-sid|ubuntu-26.10) + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + apt-get install -y -qq \ + build-essential cmake dpkg-dev extra-cmake-modules file gettext git \ + pkg-config udev libudev-dev qt6-base-dev qt6-declarative-dev \ + qt6-shadertools-dev qt6-wayland-dev libkf6config-dev \ + libkf6configwidgets-dev libkf6coreaddons-dev libkf6dbusaddons-dev \ + libkf6globalaccel-dev libkf6i18n-dev libkf6notifications-dev \ + libkf6package-dev libkirigami-dev libkf6widgetsaddons-dev \ + libkf6windowsystem-dev libkf6xmlgui-dev libkpipewire-dev \ + libplasma-dev liblayershellqtinterface-dev + ;; + *) + echo "error: unsupported CI target: $target" >&2 + exit 2 + ;; +esac + +case "$target" in + debian-testing) + . /etc/os-release + if [[ "${VERSION_CODENAME:-}" != "forky" ]]; then + echo "error: debian-testing no longer identifies as forky" >&2 + exit 1 + fi + ;; + ubuntu-26.10) + . /etc/os-release + if [[ "${VERSION_ID:-}" != "26.10" ]]; then + echo "error: ubuntu:devel is no longer Ubuntu 26.10" >&2 + exit 1 + fi + ;; +esac diff --git a/cmake/modules/ArchiveVersionInfo.cmake.in b/cmake/modules/ArchiveVersionInfo.cmake.in index f83b832c..ceb934ab 100644 --- a/cmake/modules/ArchiveVersionInfo.cmake.in +++ b/cmake/modules/ArchiveVersionInfo.cmake.in @@ -9,7 +9,9 @@ set(@prefix@_VERSION_DISTANCE "@VERSION_DISTANCE@") set(@prefix@_VERSION_SHORTHASH "@VERSION_SHORTHASH@") set(@prefix@_VERSION_FULLHASH "@VERSION_FULLHASH@") set(@prefix@_VERSION_STRING "@VERSION_STRING@") +set(@prefix@_VERSION_STRING_FULL "@VERSION_STRING_FULL@") set(@prefix@_VERSION_ISDIRTY "@VERSION_ISDIRTY@") set(@prefix@_VERSION_BRANCH "@VERSION_BRANCH@") +set(@prefix@_VERSION_BUILDTYPE "@VERSION_BUILDTYPE@") set(@prefix@_VERSION_DATE_MONTH_YEAR "@VERSION_DATE_MONTH_YEAR@") set(@prefix@_VERSION_SUCCESS 1) diff --git a/cmake/modules/GitVersion.cc.in b/cmake/modules/GitVersion.cc.in index d2f4e557..0f470e2a 100644 --- a/cmake/modules/GitVersion.cc.in +++ b/cmake/modules/GitVersion.cc.in @@ -1,7 +1,7 @@ #include "@TARGET@-GitVersion.h" namespace @TARGET@ { - const char* version_string() { return "@VERSION_STRING@"; } + const char* version_string() { return "@VERSION_STRING_FULL@"; } unsigned int version_major() { return @VERSION_MAJOR@; } unsigned int version_minor() { return @VERSION_MINOR@; } unsigned int version_patch() { return @VERSION_PATCH@; } diff --git a/cmake/modules/LinuxDistributionInfo.cmake b/cmake/modules/LinuxDistributionInfo.cmake index 5b9df22b..b8c6dd35 100644 --- a/cmake/modules/LinuxDistributionInfo.cmake +++ b/cmake/modules/LinuxDistributionInfo.cmake @@ -1,5 +1,5 @@ # This file is part of Projecteur - https://github.com/jahnf/projecteur - See LICENSE.md and README.md -cmake_minimum_required(VERSION 3.6) +cmake_minimum_required(VERSION 3.20) # Try to get the Linux distribution and version as a string (host system) # When cross compiling this function won't work to get the target distribution. @@ -35,7 +35,7 @@ function(get_linux_distribution VAR_DIST_NAME VAR_DIST_VERSION) endforeach() # Get distribution version/release - try different keys - foreach(var VERSION_ID DISTRIB_RELEASE VERSION) + foreach(var VERSION_ID VERSION_CODENAME UBUNTU_CODENAME DISTRIB_RELEASE VERSION) foreach(line IN LISTS rel_info_all) if( "${line}" MATCHES "^${var}=[\"]?([^ \"]*)") string(STRIP "${CMAKE_MATCH_1}" DIST_VERSION) diff --git a/cmake/modules/LinuxPackaging.cmake b/cmake/modules/LinuxPackaging.cmake index 29bd9e60..5774974a 100644 --- a/cmake/modules/LinuxPackaging.cmake +++ b/cmake/modules/LinuxPackaging.cmake @@ -1,5 +1,5 @@ # This file is part of Projecteur - https://github.com/jahnf/projecteur - See LICENSE.md and README.md -cmake_minimum_required(VERSION 3.6) +cmake_minimum_required(VERSION 3.20) include(LinuxDistributionInfo) set(_LinuxPackaging_DIRECTORY "${CMAKE_CURRENT_LIST_DIR}") @@ -8,6 +8,7 @@ list(APPEND _LinuxPackaging_MAP_dist_pkgtype "debian::DEB" "ubuntu::DEB" "opensuse::RPM" + "opensuse-tumbleweed::RPM" "opensuse-leap::RPM" "fedora::RPM" "centos::RPM" @@ -171,10 +172,6 @@ function(add_dist_package_target) _cpack_default_packaging() endif() - configure_file( - "${_LinuxPackaging_DIRECTORY}/travis-ci-bintray-deploy.json.in" - "${CMAKE_CURRENT_BINARY_DIR}/travis-ci-bintray-deploy.json" @ONLY) - message(STATUS "Configured target 'dist-package' with Linux '${PKG_DIST}' and package type '${PKG_TYPE}'") # Make some information available to parent scope @@ -276,7 +273,7 @@ endfunction() # Default cpack packaging (DEB, RPM, TGZ) function(_cpack_default_packaging) - set(PKG_CPACK_PKG_FILENAME "${PKG_NAME}-${PKG_VERSION_STRING}_${PKG_DIST}-${CMAKE_SYSTEM_PROCESSOR}") + set(PKG_CPACK_PKG_FILENAME "${PKG_NAME}-${PKG_VERSION_STRING_BASE}-1_${PKG_DIST}-${CMAKE_SYSTEM_PROCESSOR}") set(PKG_CPACK_PKG_FILE_PREFIX "dist-pkg") set(PKG_CONFIG_TEMPLATE "${_LinuxPackaging_DIRECTORY}/LinuxPkgCPackConfig.cmake.in") set(PKG_CONFIG_FILE "${CMAKE_CURRENT_BINARY_DIR}/CPackConfig-${PKG_TYPE}.cmake") @@ -295,7 +292,7 @@ function(add_source_archive_target target) find_program(TAR_EXECUTABLE tar) find_program(GZIP_EXECUTABLE gzip) if(GIT_FOUND) - get_target_property(VERSION_STRING ${target} VERSION_STRING) + get_target_property(VERSION_STRING ${target} VERSION_STRING_FULL) execute_process(COMMAND ${GIT_EXECUTABLE} describe --always RESULT_VARIABLE result OUTPUT_VARIABLE GIT_TREEISH diff --git a/cmake/modules/LinuxPkgCPackConfig.cmake.in b/cmake/modules/LinuxPkgCPackConfig.cmake.in index 4f567623..22a897d4 100644 --- a/cmake/modules/LinuxPkgCPackConfig.cmake.in +++ b/cmake/modules/LinuxPkgCPackConfig.cmake.in @@ -19,6 +19,7 @@ set(CPACK_PACKAGE_FILE_NAME "@PKG_CPACK_PKG_FILENAME@") set(CPACK_OUTPUT_FILE_PREFIX "@PKG_CPACK_PKG_FILE_PREFIX@") set(CPACK_DEBIAN_PACKAGE_NAME "${CPACK_PACKAGE_NAME}") +set(CPACK_DEBIAN_PACKAGE_SHLIBDEPS ON) set(CPACK_RPM_PACKAGE_NAME "${CPACK_PACKAGE_NAME}") set(CPACK_RPM_COMPRESSION_TYPE gzip) set(CPACK_DEBIAN_PACKAGE_VERSION "@PKG_VERSION_STRING_BASE@") @@ -46,8 +47,10 @@ set(CPACK_DEBIAN_PACKAGE_SECTION "@PKG_DEBIAN_SECTION@") set(CPACK_DEBIAN_COMPRESSION_TYPE xz) # Set requires/depends -set(CPACK_RPM_PACKAGE_REQUIRES "@PKG_DEPENDENCIES@") -set(CPACK_DEBIAN_PACKAGE_DEPENDS "@PKG_DEPENDENCIES@") +if(NOT "@PKG_DEPENDENCIES@" STREQUAL "") + set(CPACK_RPM_PACKAGE_REQUIRES "@PKG_DEPENDENCIES@") + set(CPACK_DEBIAN_PACKAGE_DEPENDS "@PKG_DEPENDENCIES@") +endif() # Post and Pre-install actions if necessary set(CPACK_RPM_PRE_INSTALL_SCRIPT_FILE "@PKG_PREINST_SCRIPT@") @@ -69,4 +72,3 @@ set(CPACK_DEBIAN_PACKAGE_CONTROL_STRICT_PERMISSION TRUE) if(NUM_PKG_CTRL_SCRIPTS) set(CPACK_DEBIAN_PACKAGE_CONTROL_EXTRA "${PKG_DEBIAN_CTRL_EXTRA}") endif() - diff --git a/cmake/modules/PkgDependenciesProjecteur.cmake b/cmake/modules/PkgDependenciesProjecteur.cmake index eeef027f..39f8463d 100644 --- a/cmake/modules/PkgDependenciesProjecteur.cmake +++ b/cmake/modules/PkgDependenciesProjecteur.cmake @@ -1,61 +1,41 @@ -list(APPEND _PkgDeps_Projecteur_opensuse - "libqt5-qtgraphicaleffects >= 5.7" - "libQt5Widgets5 >= 5.7" - "libQt5X11Extras5 >= 5.7" - "libQt5DBus5 >= 5.7" - "shadow" - "udev" -) - -list(APPEND _PkgDeps_Projecteur_fedora - "qt5-qtbase >= 5.7" - "qt5-qtdeclarative >= 5.7" - "qt5-qtgraphicaleffects >= 5.7" - "qt5-qtx11extras >= 5.7" - "passwd" - "udev" -) - -list(APPEND _PkgDeps_Projecteur_centos - "qt5-qtbase >= 5.7" - "qt5-qtdeclarative >= 5.7" - "qt5-qtgraphicaleffects >= 5.7" - "qt5-qtx11extras >= 5.7" - "passwd" - "udev" -) - -list(APPEND _PkgDeps_Projecteur_debian - "qml-module-qtgraphicaleffects (>= 5.7)" - "libqt5widgets5 (>= 5.7)" - "libqt5x11extras5 (>= 5.7)" - "passwd" - "udev" - "libc6" -) - list(APPEND _PkgDeps_Projecteur_archlinux - "qt5-base>=5.7" - "qt5-declarative>=5.7" - "qt5-graphicaleffects>=5.7" - "qt5-x11extras>=5.7" + "qt6-base>=6.10" + "qt6-declarative>=6.10" + "qt6-wayland>=6.10" + "layer-shell-qt>=6.7" + "kconfig>=6.7" + "kconfigwidgets>=6.7" + "kcoreaddons>=6.7" + "kdbusaddons>=6.7" + "kglobalaccel>=6.7" + "ki18n>=6.7" + "kpipewire>=6.7" + "knotifications>=6.7" + "kwidgetsaddons>=6.7" + "kwindowsystem>=6.7" + "kxmlgui>=6.7" + "libplasma>=6.7" "udev" ) list(APPEND _PkgDepsMake_Projecteur_archlinux - "fakeroot" "awk" "cmake" "make" "lsb-release" "tar" "pkg-config" "qt5-tools" + "fakeroot" "awk" "cmake>=3.20" "extra-cmake-modules>=6.7" "gettext" "make" "lsb-release" + "tar" "pkg-config" "qt6-shadertools>=6.10" ) +set(_PkgDeps_Projecteur_debian "udev") +set(_PkgDeps_Projecteur_ubuntu "udev") +set(_PkgDeps_Projecteur_fedora "systemd-udev") +set(_PkgDeps_Projecteur_opensuse "udev") + list(APPEND PkgDependencies_MAP_Projecteur + "archlinux::_PkgDeps_Projecteur_archlinux" + "arch::_PkgDeps_Projecteur_archlinux" "debian::_PkgDeps_Projecteur_debian" - "ubuntu::_PkgDeps_Projecteur_debian" + "ubuntu::_PkgDeps_Projecteur_ubuntu" "fedora::_PkgDeps_Projecteur_fedora" - "centos::_PkgDeps_Projecteur_centos" - "rhel::_PkgDeps_Projecteur_centos" "opensuse::_PkgDeps_Projecteur_opensuse" - "opensuse-leap::_PkgDeps_Projecteur_opensuse" - "archlinux::_PkgDeps_Projecteur_archlinux" - "arch::_PkgDeps_Projecteur_archlinux" + "opensuse-tumbleweed::_PkgDeps_Projecteur_opensuse" ) list(APPEND PkgDependenciesMake_MAP_Projecteur diff --git a/cmake/modules/Translation.cmake b/cmake/modules/Translation.cmake deleted file mode 100644 index 5eccc7c6..00000000 --- a/cmake/modules/Translation.cmake +++ /dev/null @@ -1,153 +0,0 @@ -find_package(Qt5 REQUIRED COMPONENTS Core) - -# Extract the qmake executable location -get_target_property(Qt5_QMAKE_EXECUTABLE Qt5::qmake IMPORTED_LOCATION) - -# Find Qts own translations dir (containing qt_*.qm, qtbase_*.qm ...) -if(NOT QT_TRANSLATIONS_DIR) - # Ask Qt5 where to put the translations - execute_process(COMMAND ${Qt5_QMAKE_EXECUTABLE} -query QT_INSTALL_TRANSLATIONS - OUTPUT_VARIABLE qt_translations_dir OUTPUT_STRIP_TRAILING_WHITESPACE) - # For windows systems: replace \ with / in directory path - file(TO_CMAKE_PATH "${qt_translations_dir}" qt_translations_dir) - set(QT_TRANSLATIONS_DIR ${qt_translations_dir} CACHE PATH "The location of the Qt translations" FORCE) -endif() - -find_package(Qt5LinguistTools QUIET) -if(NOT Qt5_LRELEASE_EXECUTABLE) - execute_process(COMMAND ${Qt5_QMAKE_EXECUTABLE} -query QT_INSTALL_BINS - OUTPUT_VARIABLE _qt_bin_dir OUTPUT_STRIP_TRAILING_WHITESPACE) - # For windows systems: replace \ with / in directory path - file(TO_CMAKE_PATH "${_qt_bin_dir}" _qt_bin_dir) - set(Qt5_LRELEASE_EXECUTABLE ${_qt_bin_dir}/lrelease) - set(Qt5_LCONVERT_EXECUTABLE ${_qt_bin_dir}/lconvert) - set(Qt5_LUPDATE_EXECUTABLE ${_qt_bin_dir}/lupdate) -else() - get_target_property(Qt5_LCONVERT_EXECUTABLE Qt5::lconvert IMPORTED_LOCATION) -endif() - -# Helper function, takes the .qm file to be generated and a variable list of .ts files -# to create a custom command that then can be used for a custom target. -function(add_qm_translation_file _qm_file) - foreach(_current_FILE ${ARGN}) - get_filename_component(_abs_FILE ${_current_FILE} ABSOLUTE) - list(APPEND _ts_files ${_abs_FILE}) - endforeach() - foreach(tsfile ${_ts_files}) - SET(tsfiles_blank_sep "${tsfiles_blank_sep} ${tsfile}") - endforeach() - add_custom_command(OUTPUT ${_qm_file} - COMMAND ${Qt5_LRELEASE_EXECUTABLE} - ARGS ${_ts_files} -qm ${_qm_file} - DEPENDS ${_ts_files} VERBATIM - COMMENT "Executing: lrelease -silent ${tsfiles_blank_sep} -qm ${_qm_file}" - ) -endfunction() - -# Helper function, takes the qrc filename to generate and a variable list .qm files to be included. -function(mk_translation_qrc_file _qrc_file) - if(NOT EXISTS ${_qrc_file}) - file(WRITE ${_qrc_file} "\n") - file(APPEND ${_qrc_file} " \n") - foreach(_qm_file ${ARGN}) - get_filename_component(filename "${_qm_file}" NAME) - file(APPEND ${_qrc_file} " ${_qm_file}\n") - endforeach() - file(APPEND ${_qrc_file} " \n") - file(APPEND ${_qrc_file} "\n") - endif() -endfunction() - -# Helper function, takes the .qm file to be generated and a variable list .qm files -# to be combined to one. Creates a custom command for the .qm file to be created. -function(add_combined_qm_translation_file _combined_qm_file) - foreach(_current_FILE ${ARGN}) - get_filename_component(_abs_FILE ${_current_FILE} ABSOLUTE) - list(APPEND _single_qm_files ${_abs_FILE}) - endforeach() - list(REMOVE_DUPLICATES _single_qm_files) - add_custom_command(OUTPUT ${_combined_qm_file} - COMMAND ${Qt5_LCONVERT_EXECUTABLE} - ARGS -o ${_combined_qm_file} ${_single_qm_files} - DEPENDS ${_single_qm_files} VERBATIM - COMMENT "Executing: ${Qt5_LCONVERT_EXECUTABLE} -o ${_combined_qm_file} ${_single_qm_files}" - ) -endfunction() - -if(NOT TARGET ts_files) - add_custom_target(ts_files) - set_target_properties(ts_files PROPERTIES FOLDER "translation") - set_target_properties(ts_files PROPERTIES EXCLUDE_FROM_DEFAULT_BUILD 1) -endif() - -# Function to add an updating 'task' to the custom translations_update target. -# _prefix : prefix for the *.ts files, i.e. myprefix_de.ts -# _input_dirs : list of directories to scan for translations with lupdate -# _ourput_dir : where to produce the *.ts files -function(add_translation_update_task _prefix _input_dirs _output_dir _languages) - foreach(_lang ${_languages}) - list(APPEND _tsfiles_lupdate "${_prefix}_${_lang}.ts") - endforeach() - - set(_ts_files_tgt ts_files_${_prefix}) - add_custom_target(${_ts_files_tgt}) - set_target_properties(${_ts_files_tgt} PROPERTIES FOLDER "translation") - set_target_properties(${_ts_files_tgt} PROPERTIES EXCLUDE_FROM_DEFAULT_BUILD 1) - add_custom_command(TARGET ${_ts_files_tgt} PRE_BUILD - COMMAND ${Qt5_LUPDATE_EXECUTABLE} - ARGS ${_input_dirs} - ARGS -locations relative - ARGS -ts - ARGS -noobsolete - ARGS ${_tsfiles_lupdate} - WORKING_DIRECTORY ${_output_dir} - COMMENT "Updating translations (${_prefix})..." - ) - add_dependencies(ts_files ${_ts_files_tgt}) -endfunction() - -if(NOT TARGET qm_files) - add_custom_target(qm_files) - set_target_properties(qm_files PROPERTIES FOLDER "translation") -endif() - -# Main function to be used in the main build configuration scripts. -# Will add a target 'translations' that will create/copy all the necessary -# .qm files to the given _target_dir for the given _languages. -# This includes also the translations from qt itself. -function(add_translations_target _prefix _target_dir _ts_dirs _languages) - file(MAKE_DIRECTORY "${_target_dir}") - # for each language - foreach(_lang ${_languages}) - # find all .ts files in the given _ts_dirs for our translations - foreach(_ts_dir ${_ts_dirs}) - file(GLOB _ts_files_glob LIST_DIRECTORIES false ${_ts_dir}/*_${_lang}.ts) - list(APPEND _ts_files_all${_lang} ${_ts_files_glob}) - endforeach() - list(LENGTH _ts_files_all${_lang} _num_ts_files) - if(_num_ts_files) - set(_qm_file ${_target_dir}/${_prefix}_${_lang}.qm) - add_qm_translation_file(${_qm_file} ${_ts_files_all${_lang}}) - list(APPEND _qm_files ${_qm_file}) - endif() - endforeach() - - list(LENGTH _qm_files _num_qm_files) - if(_num_qm_files) - set(_qm_files_tgt qm_files_${_prefix}) - add_custom_target(${_qm_files_tgt} ALL DEPENDS ${_qm_files}) - - if(TARGET ${_prefix}) - set(_qrc_file translations.qrc) - mk_translation_qrc_file(${_target_dir}/${_qrc_file} ${_qm_files}) - set_property(TARGET ${_prefix} APPEND PROPERTY SOURCES "${_target_dir}/${_qrc_file}" ) - add_dependencies(${_prefix} ${_qm_files_tgt}) - else() - message(FATAL_ERROR "'${_prefix}' is not a valid target.") - endif() - - set_target_properties(${_qm_files_tgt} PROPERTIES FOLDER "translation") - set_target_properties(${_qm_files_tgt} PROPERTIES EXCLUDE_FROM_DEFAULT_BUILD 1) - add_dependencies(qm_files ${_qm_files_tgt}) - endif() -endfunction() diff --git a/cmake/modules/travis-ci-bintray-deploy.json.in b/cmake/modules/travis-ci-bintray-deploy.json.in deleted file mode 100644 index ac2fb9d1..00000000 --- a/cmake/modules/travis-ci-bintray-deploy.json.in +++ /dev/null @@ -1,30 +0,0 @@ -{ - "package": { - "name": "projecteur-@PKG_VERSION_BRANCH@", - "repo": "Projecteur", - "subject": "jahnf", - "desc": "Automated build of Projecteur.", - "website_url": "https://github.com/jahnf/Projecteur", - "issue_tracker_url": "https://github.com/jahnf/Projecteur/issues", - "vcs_url": "https://github.com/jahnf/Projecteur.git", - "github_use_tag_release_notes": false, - "licenses": ["MIT"], - "labels": ["linux", "x11", "logitech", "spotlight", "desktop", "presentation"], - "public_download_numbers": false, - "public_stats": false - }, - - "version": { - "name": "@PKG_VERSION_STRING_FULL@", - "desc": "Automated package build of Projecteur (@PKG_VERSION_STRING_FULL@)", - "released": "@PKG_DATE@", - "gpgSign": false - }, - - "files": [ - {"includePattern": "dist-pkg/(.*)", - "uploadPattern": "packages/branches/@PKG_VERSION_BRANCH@/@PKG_VERSION_STRING_FULL@/$1" - } - ], - "publish": true -} diff --git a/cmake/templates/postinst.in b/cmake/templates/postinst.in index 641c1daf..81f06a92 100755 --- a/cmake/templates/postinst.in +++ b/cmake/templates/postinst.in @@ -1,5 +1,9 @@ -# Make sure uinput module is loaded -modprobe uinput -# Reload udev rules. -udevadm control --reload-rules -udevadm trigger +# Package installation can also run in containers and image builders where +# kernel modules and the udev daemon are intentionally unavailable. +if command -v modprobe >/dev/null 2>&1; then + modprobe uinput 2>/dev/null || true +fi +if command -v udevadm >/dev/null 2>&1; then + udevadm control --reload-rules 2>/dev/null || true + udevadm trigger 2>/dev/null || true +fi diff --git a/cmake/templates/projecteur.1 b/cmake/templates/projecteur.1 index 39c65336..53ead401 100644 --- a/cmake/templates/projecteur.1 +++ b/cmake/templates/projecteur.1 @@ -35,9 +35,6 @@ Set custom config file. \fB\-d\fR, \fB\-\-device\-scan\fR Print device\-scan results. .TP -\fB\-l\fR, \fB\-\-log\-level\fR \fILEVEL\fR -Set log level, where LEVEL is one of \fBdbg\fR, \fBinf\fR, \fBwrn\fR, \fBerr\fR -.TP \fB\-D\fR \fIDEVICE\fR Additional accepted device; DEVICE = vendorId:productId e.g., \fB\-D\fR 04b3:310c; e.g. \fB\-D\fR 0x0c45:0x8101; @@ -113,3 +110,5 @@ border.opacity=[Double] (0 ... 1) zoom=[Bool] (false, true) .TP zoom.factor=[Double] (1.5 ... 20) +.TP +zoom.mode=[Value] (smooth, text, pixel) diff --git a/cmake/templates/projecteur.bash-completion b/cmake/templates/projecteur.bash-completion index 718ced85..c244bf9d 100644 --- a/cmake/templates/projecteur.bash-completion +++ b/cmake/templates/projecteur.bash-completion @@ -15,7 +15,7 @@ _projecteur() fi local options="-h --help --help-all --version -v --cfg --device-scan -m --minimize-only" - options="${options} --log-level -l --show-dialog --disable-uinput -D -c" + options="${options} --show-dialog --disable-uinput -D -c" case "$prev" in "-c") @@ -26,6 +26,7 @@ _projecteur() commands="${commands} spot.shape.star.points= spot.shape.star.innerradius= spot.shape.ngon.sides=" commands="${commands} shade= shade.opacity= shade.color= dot= dot.size= dot.color= dot.opacity=" commands="${commands} border= border.size= border.color= border.opacity= zoom= zoom.factor=" + commands="${commands} zoom.mode=" local fl=$(printf '%.1s' "$cur") [ ! "$fl" = "q" ] && compopt -o nospace @@ -85,16 +86,16 @@ _projecteur() COMPREPLY=( $(compgen -W "false true" -- $cur) ) fi ;; + "zoom.mode") + if [ "${prev_prev}" = "=" ] || [ "${cur}" = "=" ]; then + [ "${cur}" = "=" ] && cur="" + COMPREPLY=( $(compgen -W "smooth text pixel" -- $cur) ) + fi + ;; "-D") # TODO: Auto completion for devices (vendorId:productId) COMPREPLY=( $(compgen -W "0123:4567" -- $cur) ) ;; - "-l") - COMPREPLY=( $(compgen -W "dbg inf wrn err" -- $cur) ) - ;; - "--log-level") - COMPREPLY=( $(compgen -W "dbg inf wrn err" -- $cur) ) - ;; "--cfg") # Auto completion for files local IFS=$'\n' diff --git a/cmake/templates/projecteur.desktop.in b/cmake/templates/projecteur.desktop.in index 045810c0..ae3da7a9 100644 --- a/cmake/templates/projecteur.desktop.in +++ b/cmake/templates/projecteur.desktop.in @@ -2,7 +2,11 @@ Type=Application Exec=@PROJECTEUR_INSTALL_PATH@ Name=Projecteur -GenericName=Linux/X11 application for the Logitech Spotlight device. +GenericName=Wayland spotlight for Logitech presenter devices Icon=projecteur Terminal=false +StartupNotify=false +DBusActivatable=true Categories=Office;Presentation; +X-KDE-DBUS-Restricted-Interfaces=org.kde.KWin.ScreenShot2 +X-KDE-Wayland-Interfaces=zkde_screencast_unstable_v1 diff --git a/cmake/templates/projecteur.metainfo.xml b/cmake/templates/projecteur.metainfo.xml index 119ccd84..9927a68a 100644 --- a/cmake/templates/projecteur.metainfo.xml +++ b/cmake/templates/projecteur.metainfo.xml @@ -1,10 +1,15 @@ - - projecteur - Expat - Expat + + org.projecteur.Projecteur + FSFAP + MIT Projecteur Virtual pointer for the Logitech Spotlight device + + Guillaume Binet and Projecteur contributors + + org.projecteur.Projecteur.desktop + @HOMEPAGE@

@@ -19,11 +24,12 @@ usb:v046DpC53Ed* + usb:v046DpC548d* Highlight virtual pointer effect - https://raw.githubusercontent.com/jahnf/Projecteur/develop/doc/screenshot-spot.png + https://raw.githubusercontent.com/gbin/Projecteur/develop/doc/screenshot-spot.png diff --git a/devices.conf b/devices.conf index fb3a1a08..5304a544 100644 --- a/devices.conf +++ b/devices.conf @@ -12,3 +12,6 @@ 0x17ef, 0x60db, bt, Lenovo ThinkPad X1 Presenter Mouse 0x69a7, 0x9803, usb, August LP310 0x3243, 0x0122, usb, Norwii Wireless Presenter +0x3243, 0x0382, usb, Norwii N95s BLE Presenter +0x3243, 0x03a2, bt, Norwii N95s BLE Presenter +0x1ea7, 0x0002, usb, Kensington PowerPointer diff --git a/doc/LinuxRepositories.md b/doc/LinuxRepositories.md index def5ca00..5ccf4f0a 100644 --- a/doc/LinuxRepositories.md +++ b/doc/LinuxRepositories.md @@ -1,237 +1,38 @@ -# Projecteur Linux Repositories +# Projecteur packages -This document aims to list all Linux repositories where _Projecteur_ is available. +Official Projecteur release files are published exclusively through +[GitHub Releases](https://github.com/gbin/Projecteur/releases). Each release +contains checksums and GitHub-hosted provenance information. -Is something missing? Please let me know or create a pull request. +## Upstream release assets -## Official Repositories +Projecteur currently builds x86-64 packages for: -### Debian (and Debian based distributions) +- Arch Linux and Arch-based distributions (`.pkg.tar.zst`) +- Fedora 44 (`.rpm`) +- openSUSE Tumbleweed (`.rpm`) +- Debian testing (`.deb`) +- Ubuntu 26.10 (`.deb`) -The stable version of _Projecteur_ is available in Debian starting -with _Debian bullseye_. -See [this listing](https://packages.debian.org/search?keywords=projecteur&searchon=names&suite=all§ion=all) -for all available `projecteur` packages in Debian. +These files are standalone release downloads, not package repositories. Your +package manager can install a downloaded file and resolve its dependencies from +the distribution's normal repositories. -### Ubuntu +Fedora Rawhide and Debian sid are continuous compatibility checks. They do not +produce additional release downloads because their packages would duplicate a +supported target while becoming stale quickly. -Thanks to debian packages, _Projecteur_ is available in the official Ubuntu repositories -from Ubuntu 20.10 on. See: https://packages.ubuntu.com/search?keywords=projecteur&searchon=names +## Distribution repositories -### Gentoo Linux +Some distributions independently package Projecteur. Their versions and +support schedules are controlled by the respective maintainers: -See: https://packages.gentoo.org/packages/x11-misc/projecteur +- [Debian](https://packages.debian.org/search?keywords=projecteur&searchon=names&suite=all§ion=all) +- [Ubuntu](https://packages.ubuntu.com/search?keywords=projecteur&searchon=names) +- [Gentoo](https://packages.gentoo.org/packages/x11-misc/projecteur) +- [Arch User Repository](https://aur.archlinux.org/packages?K=projecteur) +- [openSUSE Software](https://software.opensuse.org/search?baseproject=ALL&q=projecteur) -## User Repositories - -### Arch Linux - -* https://aur.archlinux.org/packages/projecteur/ -* https://aur.archlinux.org/packages/projecteur-git/ - -### OpenSUSE - -User/community repositories: -* https://software.opensuse.org/package/projecteur?search_term=projecteur - -### Projecteur's Development Repositories - -Automated project builds from the development branch of _Projecteur_ are also -uploaded to [cloudsmith.io](https://cloudsmith.io/~jahnf/repos/projecteur-develop/packages/) -and are accessible as a Linux repository for different distributions. - -See also: - * https://cloudsmith.io/~jahnf/repos/projecteur-develop/setup/#formats-deb - * https://cloudsmith.io/~jahnf/repos/projecteur-develop/setup/#formats-rpm - -[![Cloudsmith OSS Hosting](https://img.shields.io/badge/OSS%20hosting%20by-cloudsmith-blue?logo=cloudsmith&style=for-the-badge)](https://cloudsmith.com) - -#### Debian Stretch - -```sh -apt-get install -y debian-keyring -apt-get install -y debian-archive-keyring -apt-get install -y apt-transport-https -curl -1sLf 'https://dl.cloudsmith.io/public/jahnf/projecteur-develop/cfg/gpg/gpg.544E6934C0570750.key' | apt-key add - -curl -1sLf 'https://dl.cloudsmith.io/public/jahnf/projecteur-develop/cfg/setup/config.deb.txt?distro=debian&codename=stretch' > /etc/apt/sources.list.d/jahnf-projecteur-develop.list -apt-get update -``` - -#### Debian Buster - -```sh -apt-get install -y debian-keyring -apt-get install -y debian-archive-keyring -apt-get install -y apt-transport-https -curl -1sLf 'https://dl.cloudsmith.io/public/jahnf/projecteur-develop/cfg/gpg/gpg.544E6934C0570750.key' | apt-key add - -curl -1sLf 'https://dl.cloudsmith.io/public/jahnf/projecteur-develop/cfg/setup/config.deb.txt?distro=debian&codename=buster' > /etc/apt/sources.list.d/jahnf-projecteur-develop.list -apt-get update -``` - -#### Debian Bullseye - -```sh -apt-get install -y debian-keyring -apt-get install -y debian-archive-keyring -apt-get install -y apt-transport-https -curl -1sLf 'https://dl.cloudsmith.io/public/jahnf/projecteur-develop/cfg/gpg/gpg.544E6934C0570750.key' | apt-key add - -curl -1sLf 'https://dl.cloudsmith.io/public/jahnf/projecteur-develop/cfg/setup/config.deb.txt?distro=debian&codename=bullseye' > /etc/apt/sources.list.d/jahnf-projecteur-develop.list -apt-get update -``` - -#### Debian Bookworm - -```sh -apt-get install -y debian-keyring -apt-get install -y debian-archive-keyring -apt-get install -y apt-transport-https -curl -1sLf 'https://dl.cloudsmith.io/public/jahnf/projecteur-develop/cfg/gpg/gpg.544E6934C0570750.key' | apt-key add - -curl -1sLf 'https://dl.cloudsmith.io/public/jahnf/projecteur-develop/cfg/setup/config.deb.txt?distro=debian&codename=bookworm' > /etc/apt/sources.list.d/jahnf-projecteur-develop.list -apt-get update -``` - -#### Ubuntu 18.04 - -```sh -apt-get install -y apt-transport-https -curl -1sLf 'https://dl.cloudsmith.io/public/jahnf/projecteur-develop/cfg/gpg/gpg.544E6934C0570750.key' | apt-key add - -curl -1sLf 'https://dl.cloudsmith.io/public/jahnf/projecteur-develop/cfg/setup/config.deb.txt?distro=ubuntu&codename=bionic' > /etc/apt/sources.list.d/jahnf-projecteur-develop.list -apt-get update -``` - -#### Ubuntu 20.04 - -```sh -apt-get install -y apt-transport-https -curl -1sLf 'https://dl.cloudsmith.io/public/jahnf/projecteur-develop/cfg/gpg/gpg.544E6934C0570750.key' | apt-key add - -curl -1sLf 'https://dl.cloudsmith.io/public/jahnf/projecteur-develop/cfg/setup/config.deb.txt?distro=ubuntu&codename=focal' > /etc/apt/sources.list.d/jahnf-projecteur-develop.list -apt-get update -``` - -#### Ubuntu 22.04 - -```sh -apt-get install -y apt-transport-https -curl -1sLf 'https://dl.cloudsmith.io/public/jahnf/projecteur-develop/cfg/gpg/gpg.544E6934C0570750.key' | apt-key add - -curl -1sLf 'https://dl.cloudsmith.io/public/jahnf/projecteur-develop/cfg/setup/config.deb.txt?distro=ubuntu&codename=jammy' > /etc/apt/sources.list.d/jahnf-projecteur-develop.list -apt-get update -``` - -#### Ubuntu 23.04 - -```sh -apt-get install -y apt-transport-https -curl -1sLf 'https://dl.cloudsmith.io/public/jahnf/projecteur-develop/cfg/gpg/gpg.544E6934C0570750.key' | apt-key add - -curl -1sLf 'https://dl.cloudsmith.io/public/jahnf/projecteur-develop/cfg/setup/config.deb.txt?distro=ubuntu&codename=lunar' > /etc/apt/sources.list.d/jahnf-projecteur-develop.list -apt-get update -``` - -#### OpenSuse 15.1 - -```sh -curl -1sLf 'https://dl.cloudsmith.io/public/jahnf/projecteur-develop/cfg/setup/config.rpm.txt?distro=opensuse&codename=15.1' > /tmp/jahnf-projecteur-develop.repo -zypper ar -f '/tmp/jahnf-projecteur-develop.repo' -zypper --gpg-auto-import-keys refresh jahnf-projecteur-develop jahnf-projecteur-develop-source -``` - -#### OpenSuse 15.2 - -```sh -curl -1sLf 'https://dl.cloudsmith.io/public/jahnf/projecteur-develop/cfg/setup/config.rpm.txt?distro=opensuse&codename=15.2' > /tmp/jahnf-projecteur-develop.repo -zypper ar -f '/tmp/jahnf-projecteur-develop.repo' -zypper --gpg-auto-import-keys refresh jahnf-projecteur-develop jahnf-projecteur-develop-source -``` - -#### OpenSuse 15.3 - -```sh -curl -1sLf 'https://dl.cloudsmith.io/public/jahnf/projecteur-develop/cfg/setup/config.rpm.txt?distro=opensuse&codename=15.3' > /tmp/jahnf-projecteur-develop.repo -zypper ar -f '/tmp/jahnf-projecteur-develop.repo' -zypper --gpg-auto-import-keys refresh jahnf-projecteur-develop jahnf-projecteur-develop-source -``` - -#### OpenSuse 15.4 - -```sh -curl -1sLf 'https://dl.cloudsmith.io/public/jahnf/projecteur-develop/cfg/setup/config.rpm.txt?distro=opensuse&codename=15.4' > /tmp/jahnf-projecteur-develop.repo -zypper ar -f '/tmp/jahnf-projecteur-develop.repo' -zypper --gpg-auto-import-keys refresh jahnf-projecteur-develop jahnf-projecteur-develop-source -``` - -#### OpenSuse 15.5 - -```sh -curl -1sLf 'https://dl.cloudsmith.io/public/jahnf/projecteur-develop/cfg/setup/config.rpm.txt?distro=opensuse&codename=15.5' > /tmp/jahnf-projecteur-develop.repo -zypper ar -f '/tmp/jahnf-projecteur-develop.repo' -zypper --gpg-auto-import-keys refresh jahnf-projecteur-develop jahnf-projecteur-develop-source -``` - -#### Fedora 31 - - ```sh -dnf install yum-utils pygpgme -rpm --import 'https://dl.cloudsmith.io/public/jahnf/projecteur-develop/cfg/gpg/gpg.544E6934C0570750.key' -curl -1sLf 'https://dl.cloudsmith.io/public/jahnf/projecteur-develop/cfg/setup/config.rpm.txt?distro=fedora&codename=31' > /tmp/jahnf-projecteur-develop.repo -dnf config-manager --add-repo '/tmp/jahnf-projecteur-develop.repo' -dnf -q makecache -y --disablerepo='*' --enablerepo='jahnf-projecteur-develop' --enablerepo='jahnf-projecteur-develop-source' -``` - -#### Fedora 32 - -```sh -dnf install yum-utils pygpgme -rpm --import 'https://dl.cloudsmith.io/public/jahnf/projecteur-develop/cfg/gpg/gpg.544E6934C0570750.key' -curl -1sLf 'https://dl.cloudsmith.io/public/jahnf/projecteur-develop/cfg/setup/config.rpm.txt?distro=fedora&codename=32' > /tmp/jahnf-projecteur-develop.repo -dnf config-manager --add-repo '/tmp/jahnf-projecteur-develop.repo' -dnf -q makecache -y --disablerepo='*' --enablerepo='jahnf-projecteur-develop' --enablerepo='jahnf-projecteur-develop-source' -``` - -#### Fedora 33 - -```sh -dnf install yum-utils pygpgme -rpm --import 'https://dl.cloudsmith.io/public/jahnf/projecteur-develop/cfg/gpg/gpg.544E6934C0570750.key' -curl -1sLf 'https://dl.cloudsmith.io/public/jahnf/projecteur-develop/cfg/setup/config.rpm.txt?distro=fedora&codename=33' > /tmp/jahnf-projecteur-develop.repo -dnf config-manager --add-repo '/tmp/jahnf-projecteur-develop.repo' -dnf -q makecache -y --disablerepo='*' --enablerepo='jahnf-projecteur-develop' --enablerepo='jahnf-projecteur-develop-source' -``` - -#### Fedora 34 - -```sh -dnf install yum-utils pygpgme -rpm --import 'https://dl.cloudsmith.io/public/jahnf/projecteur-develop/cfg/gpg/gpg.544E6934C0570750.key' -curl -1sLf 'https://dl.cloudsmith.io/public/jahnf/projecteur-develop/cfg/setup/config.rpm.txt?distro=fedora&codename=34' > /tmp/jahnf-projecteur-develop.repo -dnf config-manager --add-repo '/tmp/jahnf-projecteur-develop.repo' -dnf -q makecache -y --disablerepo='*' --enablerepo='jahnf-projecteur-develop' --enablerepo='jahnf-projecteur-develop-source' -``` - -#### Fedora 37 - -```sh -dnf install yum-utils pygpgme -rpm --import 'https://dl.cloudsmith.io/public/jahnf/projecteur-develop/cfg/gpg/gpg.544E6934C0570750.key' -curl -1sLf 'https://dl.cloudsmith.io/public/jahnf/projecteur-develop/cfg/setup/config.rpm.txt?distro=fedora&codename=37' > /tmp/jahnf-projecteur-develop.repo -dnf config-manager --add-repo '/tmp/jahnf-projecteur-develop.repo' -dnf -q makecache -y --disablerepo='*' --enablerepo='jahnf-projecteur-develop' --enablerepo='jahnf-projecteur-develop-source' -``` - -#### Fedora 38 - -```sh -dnf install yum-utils pygpgme -rpm --import 'https://dl.cloudsmith.io/public/jahnf/projecteur-develop/cfg/gpg/gpg.544E6934C0570750.key' -curl -1sLf 'https://dl.cloudsmith.io/public/jahnf/projecteur-develop/cfg/setup/config.rpm.txt?distro=fedora&codename=38' > /tmp/jahnf-projecteur-develop.repo -dnf config-manager --add-repo '/tmp/jahnf-projecteur-develop.repo' -dnf -q makecache -y --disablerepo='*' --enablerepo='jahnf-projecteur-develop' --enablerepo='jahnf-projecteur-develop-source' -``` - -#### CentOS 8 - -```sh -yum install yum-utils pygpgme -rpm --import 'https://dl.cloudsmith.io/public/jahnf/projecteur-develop/cfg/gpg/gpg.544E6934C0570750.key' -curl -1sLf 'https://dl.cloudsmith.io/public/jahnf/projecteur-develop/cfg/setup/config.rpm.txt?distro=el&codename=8' > /tmp/jahnf-projecteur-develop.repo -yum-config-manager --add-repo '/tmp/jahnf-projecteur-develop.repo' -yum -q makecache -y --disablerepo='*' --enablerepo='jahnf-projecteur-develop' -``` +Check that a downstream package is the Plasma 6 / Wayland edition before +installing it. The older Qt 5 edition remains available from the +[`legacy/qt5`](https://github.com/gbin/Projecteur/tree/legacy/qt5) branch. diff --git a/doc/LogitechSpotlightHID++.md b/doc/LogitechSpotlightHID++.md index 3159cee9..d546728f 100644 --- a/doc/LogitechSpotlightHID++.md +++ b/doc/LogitechSpotlightHID++.md @@ -90,6 +90,8 @@ enum class FeatureCode : uint16_t { Reset = 0x0020, DFUControlSigned = 0x00c2, BatteryStatus = 0x1000, + UnifiedBattery = 0x1004, + Haptic = 0x19b0, PresenterControl = 0x1a00, Sensor3D = 0x1a01, ReprogramControlsV4 = 0x1b04, @@ -236,6 +238,12 @@ The spotlight device can vibrate if the HID++ message `{0x10, 0x01, (Feature Index for Presenter Control Feature Code), 0x1d, length, 0xe8, intensity}` is sent to it. In the message, length can range between `0x00` to `0x0a`. +The Logitech Spotlight 2 instead exposes the Haptic Feature Code (`0x19b0`). +Projecteur first sets its global haptic level with function `0x02` and payload +`{enabled, level}`, where level is a percentage. It then plays the built-in +Completed waveform (`0x07`) with function `0x04`. Unlike the original +Presenter Control command, this feature does not accept a vibration length. + ### Battery Status Battery status can be requested by sending request command @@ -258,6 +266,12 @@ enum class BatteryStatus : uint8_t {Discharging = 0x00, }; ``` +Newer devices such as the Logitech Spotlight 2 use the Unified Battery Feature +Code (`0x1004`). Its `getStatus` request uses function code `0x01`; the response +contains the current discharge percentage in the fifth byte, an approximate +battery level in the sixth byte, and the same battery status value in the +seventh byte. + ## Processing of device response All of the HID++ commands listed above result in response messages from the diff --git a/doc/TROUBLESHOOTING.md b/doc/TROUBLESHOOTING.md new file mode 100644 index 00000000..e3fc8c26 --- /dev/null +++ b/doc/TROUBLESHOOTING.md @@ -0,0 +1,107 @@ +# Troubleshooting Projecteur + +Start with the checks below. If you still need help, open an issue in the +[Projecteur issue tracker](https://github.com/gbin/Projecteur/issues) and +include: + +```sh +projecteur --fullversion +projecteur --device-scan +``` + +Also mention your Linux distribution, Plasma version, Qt version, presenter +model, and whether it is connected over USB or Bluetooth. + +## Confirm the supported desktop + +The current development line requires KDE Plasma on Wayland. Check the session: + +```sh +printf '%s\n' "$XDG_CURRENT_DESKTOP" +printf '%s\n' "$XDG_SESSION_TYPE" +``` + +The session type must report `wayland`. Do not force +`QT_QPA_PLATFORM=xcb`; X11 is not supported by the current development line. + +## Presenter is not connected + +Ask Projecteur to list supported devices and their access state: + +```sh +projecteur --device-scan +``` + +If the presenter is detected but is not readable or writable, reload the +installed udev rules and reconnect it: + +```sh +sudo udevadm control --reload-rules +sudo udevadm trigger +``` + +You can also confirm that Linux sees a Logitech input device: + +```sh +grep -A 5 "Vendor=046d" /proc/bus/input/devices +``` + +For the relevant `/dev/input/eventN` path, check access: + +```sh +test -r /dev/input/eventN && echo readable || echo not-readable +test -w /dev/input/eventN && echo writable || echo not-writable +``` + +Conflicting hand-written and package-installed Projecteur rules can produce +surprising permissions. Check for duplicate `55-projecteur.rules` files under +`/etc/udev/rules.d`, `/run/udev/rules.d`, `/usr/lib/udev/rules.d`, and +`/lib/udev/rules.d`. + +## Zoom does not work + +Live zoom requires all of the following: + +- KDE Plasma on Wayland; +- KWin's restricted screencast protocol; +- KPipeWire; +- an installed Projecteur desktop entry that matches the running executable. + +Install Projecteur instead of launching only `build/projecteur`. KWin authorizes +the capture interface using the installed +`org.projecteur.Projecteur.desktop` metadata and can reject an executable from an +arbitrary build path. + +Projecteur uses KWin's low-latency stream for normal live zoom and falls back to +KWin's screenshot interface when streaming is unavailable. KWin excludes +Projecteur's own windows from capture to prevent recursive overlay images. + +## Spotlight is opaque + +Transparency requires the Plasma Wayland compositor. Confirm that +`XDG_SESSION_TYPE` is `wayland` and that the Projecteur log reports the Wayland +Qt platform plugin. + +## System tray applet is missing + +Confirm that Projecteur is running, then open the Plasma system tray +configuration and enable the Projecteur entry. Plasma owns the popup placement +and visibility. + +If the application was just upgraded, restart Plasma Shell or sign out and back +in so the updated applet package is loaded. + +## Reset the configuration + +Projecteur stores its KDE configuration in: + +```text +~/.config/projecteurrc +``` + +To test with clean settings without destroying your existing configuration, +start Projecteur with a separate file: + +```sh +projecteur --cfg /tmp/projecteur-clean-test.rc --show-dialog +``` diff --git a/doc/USER-GUIDE.md b/doc/USER-GUIDE.md new file mode 100644 index 00000000..f73de74b --- /dev/null +++ b/doc/USER-GUIDE.md @@ -0,0 +1,127 @@ +# Projecteur user guide + +This guide covers the controls you are likely to set once and rely on during +every presentation. For installation and the product overview, start with the +[README](../README.md). + +## Everyday workflow + +Launch Projecteur from the application menu. Its Plasma system tray applet shows +connected presenters and provides quick access to: + +- the current spotlight preset; +- **Test Spotlight**; +- presentation timer controls; +- preferences, About, and Quit. + +Connection, battery, access-error, and timer notifications use Plasma's native +notification system. Customize them under **System Settings → Notifications → +Applications → Projecteur**. + +Projecteur stores its settings in `~/.config/projecteurrc`. + +## Spotlight and presets + +Under **Preferences → Spotlight**, you can configure: + +- spotlight size, shape, and rotation; +- shade color and opacity; +- center dot and border; +- cursor appearance; +- zoom level and content type; +- multi-screen behavior. + +Save combinations as presets when different situations need different treatment: +for example, a small dot for slides, a large text magnifier for a code demo, and +a borderless highlight for video. + +Presets are ordered alphabetically when Projecteur starts. Prefix names with +numbers if you want a fixed cycle order, such as `1 Slides`, `2 Demo`, and +`3 Questions`. + +## Live zoom modes + +Zoom uses a low-latency stream from KWin through KPipeWire. Videos, animations, +and other changing desktop content continue updating inside the magnifier. + +| Mode | Best for | Behavior | +| --- | --- | --- | +| **Smooth (images)** | Photographs, video, gradients, and mixed content | Bilinear filtering produces continuous tones and few scaling artifacts. | +| **Text and UI** | Documents, terminals, diagrams, and application controls | Smooth scaling plus bounded edge enhancement makes interface details easier to read. | +| **Pixel-perfect** | Pixel art, source-pixel inspection, and debugging | Nearest-neighbor scaling preserves captured pixel values; text may look blocky. | + +The selected mode is stored in each preset. **Text and UI** improves the captured +raster; it cannot recover font outlines or rerender text as vectors. + +Live zoom requires Projecteur to be installed. KWin uses the installed desktop +metadata to authorize its restricted capture interfaces, so a binary launched +only from the build directory cannot use the normal zoom path. + +## Global shortcuts and device-free use + +Projecteur registers native KDE global actions for: + +- toggling the spotlight; +- opening preferences; +- starting or resetting the presentation timer; +- selecting the next or previous preset. + +No key combinations are assigned by default. Set them under **Preferences → +Shortcuts** or **System Settings → Keyboard → Shortcuts → Projecteur**. + +This also makes Projecteur useful without presenter hardware: assign **Toggle +Spotlight**, then use it while sharing your screen in a meeting or recording a +demo. + +## Presentation timer + +The system tray applet can start the timer immediately or arm it for the next +presenter button press. While it runs, the panel icon shows the remaining +minutes and its tooltip shows the precise countdown. + +When time expires, compatible presenters—including Logitech Spotlight models— +can provide configurable haptic feedback. + +## Button mapping + +Projecteur can map device input to: + +- a keyboard sequence; +- the next or previous spotlight preset; +- vertical or horizontal scrolling; +- volume control; +- other built-in presentation actions. + +Keyboard sequences are especially useful for presentation software shortcuts. +Projecteur grabs presenter events and forwards unmapped input through a virtual +uinput device. Starting with `--disable-uinput` disables both event grabbing and +button mapping. + +### Logitech hold gestures + +Logitech Spotlight devices distinguish three interactions for the Next and Back +buttons: + +1. tap; +2. long press; +3. hold while moving the presenter. + +On the Devices page in Preferences, record taps and long presses directly. To +map hold-and-move, wake the presenter with any button, right-click the input +sequence column, and choose the relevant hold-and-move input. + +Avoid mapping both long press and hold-and-move on the same button unless you +want both actions to run when the button is held during movement. + +## Command-line control + +Projecteur can control an already running instance from scripts. Common examples: + +```sh +projecteur --command spot=toggle +projecteur --command settings=show +projecteur --command preset="2 Demo" +``` + +Run `projecteur --help-all` or `man projecteur` for the complete command and +property reference. diff --git a/doc/screenshot-applet-presentation-timer.png b/doc/screenshot-applet-presentation-timer.png new file mode 100644 index 00000000..baf50646 Binary files /dev/null and b/doc/screenshot-applet-presentation-timer.png differ diff --git a/doc/screenshot-applet-timer-countdown.png b/doc/screenshot-applet-timer-countdown.png new file mode 100644 index 00000000..10060989 Binary files /dev/null and b/doc/screenshot-applet-timer-countdown.png differ diff --git a/doc/screenshot-button-mapping.png b/doc/screenshot-button-mapping.png deleted file mode 100644 index 63d600a2..00000000 Binary files a/doc/screenshot-button-mapping.png and /dev/null differ diff --git a/doc/screenshot-global-shortcuts.png b/doc/screenshot-global-shortcuts.png new file mode 100644 index 00000000..27a225fd Binary files /dev/null and b/doc/screenshot-global-shortcuts.png differ diff --git a/doc/screenshot-notification.png b/doc/screenshot-notification.png new file mode 100644 index 00000000..f25fb1aa Binary files /dev/null and b/doc/screenshot-notification.png differ diff --git a/doc/screenshot-plasma-applet.png b/doc/screenshot-plasma-applet.png new file mode 100644 index 00000000..baf2728d Binary files /dev/null and b/doc/screenshot-plasma-applet.png differ diff --git a/doc/screenshot-settings.png b/doc/screenshot-settings.png index 2f4c2f30..310416d0 100644 Binary files a/doc/screenshot-settings.png and b/doc/screenshot-settings.png differ diff --git a/doc/screenshot-spot.png b/doc/screenshot-spot.png index 6ed5d2f8..b5de5106 100644 Binary files a/doc/screenshot-spot.png and b/doc/screenshot-spot.png differ diff --git a/doc/screenshot-text-zoom.png b/doc/screenshot-text-zoom.png new file mode 100644 index 00000000..22520dee Binary files /dev/null and b/doc/screenshot-text-zoom.png differ diff --git a/doc/screenshot-traymenu.png b/doc/screenshot-traymenu.png index c35b2346..97d6a137 100644 Binary files a/doc/screenshot-traymenu.png and b/doc/screenshot-traymenu.png differ diff --git a/doc/screenshot-zoom-modes.png b/doc/screenshot-zoom-modes.png new file mode 100644 index 00000000..03a825c3 Binary files /dev/null and b/doc/screenshot-zoom-modes.png differ diff --git a/docker/Dockerfile.archlinux b/docker/Dockerfile.archlinux index 1fcee879..8fcb471f 100644 --- a/docker/Dockerfile.archlinux +++ b/docker/Dockerfile.archlinux @@ -1,5 +1,4 @@ # Container for building the Projecteur package -# Images available at: https://hub.docker.com/r/jahnf/projecteur/tags FROM archlinux @@ -14,12 +13,24 @@ RUN pacman --noconfirm -Sy && pacman --noconfirm -S \ gcc \ make \ cmake \ + gettext \ git \ - qt5-tools \ - qt5-base \ - qt5-declarative \ - qt5-x11extras \ - qt5-graphicaleffects + qt6-base \ + qt6-declarative \ + qt6-shadertools \ + extra-cmake-modules \ + kconfig \ + kconfigwidgets \ + kcoreaddons \ + kdbusaddons \ + kglobalaccel \ + ki18n \ + knotifications \ + kwidgetsaddons \ + kwindowsystem \ + kxmlgui \ + libplasma \ + layer-shell-qt RUN pacman --noconfirm -Sy && pacman --noconfirm -S \ libusb diff --git a/docker/Dockerfile.centos-8 b/docker/Dockerfile.centos-8 deleted file mode 100644 index dbd6cc2b..00000000 --- a/docker/Dockerfile.centos-8 +++ /dev/null @@ -1,18 +0,0 @@ -# Container for building the Projecteur package -# Images available at: https://hub.docker.com/r/jahnf/projecteur/tags - -FROM centos:centos8 - -RUN dnf -y install --setopt=install_weak_deps=False \ - cmake \ - udev \ - gcc-c++ \ - tar \ - make \ - git \ - qt5-qtdeclarative-devel \ - pkg-config \ - rpm-build \ - qt5-linguist \ - qt5-qtx11extras-devel \ - libusbx-devel diff --git a/docker/Dockerfile.debian-bookworm b/docker/Dockerfile.debian-bookworm deleted file mode 100644 index 705d8777..00000000 --- a/docker/Dockerfile.debian-bookworm +++ /dev/null @@ -1,23 +0,0 @@ -# Container for building the Projecteur package -# Images available at: https://hub.docker.com/r/jahnf/projecteur/tags - -FROM debian:bookworm - -RUN apt-get update && mkdir /build -RUN DEBIAN_FRONTEND="noninteractive" \ - apt-get install -y --no-install-recommends \ - ca-certificates \ - g++ \ - make \ - cmake \ - udev \ - git \ - pkg-config \ - qtdeclarative5-dev \ - qttools5-dev-tools \ - qttools5-dev \ - libqt5x11extras5-dev \ - libusb-1.0-0-dev \ - && rm -rf /var/lib/apt/lists/* - -RUN git config --global --add safe.directory /source diff --git a/docker/Dockerfile.debian-bullseye b/docker/Dockerfile.debian-bullseye deleted file mode 100644 index 371afde1..00000000 --- a/docker/Dockerfile.debian-bullseye +++ /dev/null @@ -1,20 +0,0 @@ -# Container for building the Projecteur package -# Images available at: https://hub.docker.com/r/jahnf/projecteur/tags - -FROM debian:bullseye - -RUN apt-get update -RUN DEBIAN_FRONTEND="noninteractive" \ - apt-get install -y --no-install-recommends \ - ca-certificates \ - g++ \ - make \ - cmake \ - udev \ - git \ - pkg-config \ - qtdeclarative5-dev \ - qttools5-dev-tools \ - libqt5x11extras5-dev \ - libusb-1.0-0-dev \ - && rm -rf /var/lib/apt/lists/* diff --git a/docker/Dockerfile.debian-buster b/docker/Dockerfile.debian-buster deleted file mode 100644 index bb46fdbe..00000000 --- a/docker/Dockerfile.debian-buster +++ /dev/null @@ -1,25 +0,0 @@ -# Container for building the Projecteur package -# Images available at: https://hub.docker.com/r/jahnf/projecteur/tags - -FROM debian:buster - -RUN apt-get update -RUN apt-get install -y --no-install-recommends \ - ca-certificates - -RUN apt-get install -y --no-install-recommends \ - g++ \ - make \ - cmake \ - udev \ - git \ - pkg-config - -RUN apt-get install -y --no-install-recommends \ - qtdeclarative5-dev \ - qttools5-dev-tools \ - qt5-default - -RUN apt-get install -y --no-install-recommends \ - libqt5x11extras5-dev \ - libusb-1.0-0-dev diff --git a/docker/Dockerfile.debian-stretch b/docker/Dockerfile.debian-stretch deleted file mode 100644 index 6a171116..00000000 --- a/docker/Dockerfile.debian-stretch +++ /dev/null @@ -1,40 +0,0 @@ -# Container for building the Projecteur package -# Images available at: https://hub.docker.com/r/jahnf/projecteur/tags - -FROM debian:stretch - -RUN apt-get update -RUN apt-get install -y --no-install-recommends \ - ca-certificates - -RUN apt-get install -y --no-install-recommends \ - g++ \ - make \ - cmake \ - udev \ - git \ - pkg-config - -RUN apt-get install -y --no-install-recommends \ - qtdeclarative5-dev \ - qttools5-dev-tools \ - qt5-default - -RUN apt-get install -y --no-install-recommends \ - libqt5x11extras5-dev \ - libusb-1.0-0-dev - -RUN apt-get install -y --no-install-recommends \ - libqt5x11extras5-dev \ - libusb-1.0-0-dev - -RUN apt-get install -y --no-install-recommends \ - wget - -# Install newer CMake version, -# otherwise the package version in the debian package -# created by the dist-package target will not be correct -RUN wget https://github.com/Kitware/CMake/releases/download/v3.19.6/cmake-3.19.6-Linux-x86_64.sh && \ - chmod +x cmake-3.19.6-Linux-x86_64.sh && \ - ./cmake-3.19.6-Linux-x86_64.sh --skip-license --prefix=/usr && \ - rm ./cmake-3.19.6-Linux-x86_64.sh diff --git a/docker/Dockerfile.fedora-30 b/docker/Dockerfile.fedora-30 deleted file mode 100644 index 9edda9ba..00000000 --- a/docker/Dockerfile.fedora-30 +++ /dev/null @@ -1,20 +0,0 @@ -# Container for building the Projecteur package -# Images available at: https://hub.docker.com/r/jahnf/projecteur/tags - -FROM fedora:30 - -RUN dnf -y install --setopt=install_weak_deps=False --best \ - cmake \ - udev \ - gcc-c++ \ - tar \ - make \ - git \ - qt5-qtdeclarative-devel \ - pkg-config \ - rpm-build - -RUN dnf -y install --setopt=install_weak_deps=False --best \ - qt5-linguist \ - qt5-qtx11extras-devel \ - libusbx-devel diff --git a/docker/Dockerfile.fedora-31 b/docker/Dockerfile.fedora-31 deleted file mode 100644 index b9914f5f..00000000 --- a/docker/Dockerfile.fedora-31 +++ /dev/null @@ -1,21 +0,0 @@ -# Container for building the Projecteur package -# Images available at: https://hub.docker.com/r/jahnf/projecteur/tags - -FROM fedora:31 - -RUN dnf -y install --setopt=install_weak_deps=False --best \ - cmake \ - udev \ - gcc-c++ \ - tar \ - make \ - git \ - qt5-qtdeclarative-devel \ - pkg-config \ - rpm-build - -RUN dnf -y install --setopt=install_weak_deps=False --best \ - qt5-linguist \ - qt5-qtx11extras-devel \ - libusbx-devel - diff --git a/docker/Dockerfile.fedora-32 b/docker/Dockerfile.fedora-32 deleted file mode 100644 index 7eeababc..00000000 --- a/docker/Dockerfile.fedora-32 +++ /dev/null @@ -1,20 +0,0 @@ -# Container for building the Projecteur package -# Images available at: https://hub.docker.com/r/jahnf/projecteur/tags - -FROM fedora:32 - -RUN mkdir /build -RUN dnf -y install --setopt=install_weak_deps=False --best \ - cmake \ - udev \ - gcc-c++ \ - tar \ - make \ - git \ - qt5-qtdeclarative-devel \ - pkg-config \ - rpm-build \ - qt5-linguist \ - qt5-qtx11extras-devel \ - libusbx-devel - diff --git a/docker/Dockerfile.fedora-33 b/docker/Dockerfile.fedora-33 deleted file mode 100644 index 733c1859..00000000 --- a/docker/Dockerfile.fedora-33 +++ /dev/null @@ -1,20 +0,0 @@ -# Container for building the Projecteur package -# Images available at: https://hub.docker.com/r/jahnf/projecteur/tags - -FROM fedora:33 - -RUN mkdir /build -RUN dnf -y install --setopt=install_weak_deps=False --best \ - cmake \ - udev \ - gcc-c++ \ - tar \ - make \ - git \ - qt5-qtdeclarative-devel \ - pkg-config \ - rpm-build \ - qt5-linguist \ - qt5-qtx11extras-devel \ - libusbx-devel - diff --git a/docker/Dockerfile.fedora-34 b/docker/Dockerfile.fedora-34 deleted file mode 100644 index 92d641ce..00000000 --- a/docker/Dockerfile.fedora-34 +++ /dev/null @@ -1,20 +0,0 @@ -# Container for building the Projecteur package -# Images available at: https://hub.docker.com/r/jahnf/projecteur/tags - -FROM fedora:34 - -RUN mkdir /build -RUN dnf -y install --setopt=install_weak_deps=False --best \ - cmake \ - udev \ - gcc-c++ \ - tar \ - make \ - git \ - qt5-qtdeclarative-devel \ - pkg-config \ - rpm-build \ - qt5-linguist \ - qt5-qtx11extras-devel \ - libusbx-devel - diff --git a/docker/Dockerfile.fedora-37 b/docker/Dockerfile.fedora-37 deleted file mode 100644 index a926c031..00000000 --- a/docker/Dockerfile.fedora-37 +++ /dev/null @@ -1,21 +0,0 @@ -# Container for building the Projecteur package -# Images available at: https://hub.docker.com/r/jahnf/projecteur/tags - -FROM fedora:37 - -RUN mkdir /build -RUN dnf -y install --setopt=install_weak_deps=False --best \ - cmake \ - udev \ - gcc-c++ \ - tar \ - make \ - git \ - qt5-qtdeclarative-devel \ - pkg-config \ - rpm-build \ - qt5-linguist \ - qt5-qtx11extras-devel \ - libusbx-devel - -RUN git config --global --add safe.directory /source diff --git a/docker/Dockerfile.fedora-38 b/docker/Dockerfile.fedora-38 deleted file mode 100644 index 997e5d74..00000000 --- a/docker/Dockerfile.fedora-38 +++ /dev/null @@ -1,21 +0,0 @@ -# Container for building the Projecteur package -# Images available at: https://hub.docker.com/r/jahnf/projecteur/tags - -FROM fedora:38 - -RUN mkdir /build -RUN dnf -y install --setopt=install_weak_deps=False --best \ - cmake \ - udev \ - gcc-c++ \ - tar \ - make \ - git \ - qt5-qtdeclarative-devel \ - pkg-config \ - rpm-build \ - qt5-linguist \ - qt5-qtx11extras-devel \ - libusbx-devel - -RUN git config --global --add safe.directory /source diff --git a/docker/Dockerfile.opensuse-15.0 b/docker/Dockerfile.opensuse-15.0 deleted file mode 100644 index 4d0e58b0..00000000 --- a/docker/Dockerfile.opensuse-15.0 +++ /dev/null @@ -1,26 +0,0 @@ -# Container for building the Projecteur package -# Images available at: https://hub.docker.com/r/jahnf/projecteur/tags - -FROM opensuse/leap:15.0 - -RUN zypper --non-interactive in --no-recommends \ - pkg-config \ - udev \ - gcc-c++ \ - tar \ - make \ - cmake \ - git \ - wget \ - libqt5-qtdeclarative-devel \ - rpmbuild - -RUN zypper --non-interactive in --no-recommends \ - libqt5-linguist - -RUN zypper --non-interactive in --no-recommends \ - libqt5-qtx11extras-devel \ - libusb-1_0-devel - -RUN zypper --non-interactive in --no-recommends \ - libQt5DBus-devel diff --git a/docker/Dockerfile.opensuse-15.1 b/docker/Dockerfile.opensuse-15.1 deleted file mode 100644 index a856cc2c..00000000 --- a/docker/Dockerfile.opensuse-15.1 +++ /dev/null @@ -1,26 +0,0 @@ -# Container for building the Projecteur package -# Images available at: https://hub.docker.com/r/jahnf/projecteur/tags - -FROM opensuse/leap:15.1 - -RUN zypper --non-interactive in --no-recommends \ - pkg-config \ - udev \ - gcc-c++ \ - tar \ - make \ - cmake \ - git \ - wget \ - libqt5-qtdeclarative-devel \ - rpmbuild - -RUN zypper --non-interactive in --no-recommends \ - libqt5-linguist - -RUN zypper --non-interactive in --no-recommends \ - libqt5-qtx11extras-devel \ - libusb-1_0-devel - -RUN zypper --non-interactive in --no-recommends \ - libQt5DBus-devel \ No newline at end of file diff --git a/docker/Dockerfile.opensuse-15.2 b/docker/Dockerfile.opensuse-15.2 deleted file mode 100644 index 77f53bda..00000000 --- a/docker/Dockerfile.opensuse-15.2 +++ /dev/null @@ -1,20 +0,0 @@ -# Container for building the Projecteur package -# Images available at: https://hub.docker.com/r/jahnf/projecteur/tags - -FROM opensuse/leap:15.2 - -RUN zypper --non-interactive in --no-recommends \ - pkg-config \ - udev \ - gcc-c++ \ - tar \ - make \ - cmake \ - git \ - wget \ - libqt5-qtdeclarative-devel \ - rpmbuild \ - libqt5-linguist \ - libqt5-qtx11extras-devel \ - libusb-1_0-devel \ - libQt5DBus-devel diff --git a/docker/Dockerfile.opensuse-15.3 b/docker/Dockerfile.opensuse-15.3 deleted file mode 100644 index 5eb6427f..00000000 --- a/docker/Dockerfile.opensuse-15.3 +++ /dev/null @@ -1,20 +0,0 @@ -# Container for building the Projecteur package -# Images available at: https://hub.docker.com/r/jahnf/projecteur/tags - -FROM opensuse/leap:15.3 - -RUN zypper --non-interactive in --no-recommends \ - pkg-config \ - udev \ - gcc-c++ \ - tar \ - make \ - cmake \ - git \ - wget \ - libqt5-qtdeclarative-devel \ - rpmbuild \ - libqt5-linguist \ - libqt5-qtx11extras-devel \ - libusb-1_0-devel \ - libQt5DBus-devel diff --git a/docker/Dockerfile.opensuse-15.4 b/docker/Dockerfile.opensuse-15.4 deleted file mode 100644 index 7765cdaf..00000000 --- a/docker/Dockerfile.opensuse-15.4 +++ /dev/null @@ -1,23 +0,0 @@ -# Container for building the Projecteur package -# Images available at: https://hub.docker.com/r/jahnf/projecteur/tags - -FROM opensuse/leap:15.4 - -RUN mkdir /build -RUN zypper --non-interactive in --no-recommends \ - pkg-config \ - udev \ - gcc-c++ \ - tar \ - make \ - cmake \ - git \ - wget \ - libqt5-qtdeclarative-devel \ - rpmbuild \ - libqt5-linguist \ - libqt5-qtx11extras-devel \ - libusb-1_0-devel \ - libQt5DBus-devel - -RUN git config --global --add safe.directory /source diff --git a/docker/Dockerfile.opensuse-15.5 b/docker/Dockerfile.opensuse-15.5 deleted file mode 100644 index a2f3a225..00000000 --- a/docker/Dockerfile.opensuse-15.5 +++ /dev/null @@ -1,23 +0,0 @@ -# Container for building the Projecteur package -# Images available at: https://hub.docker.com/r/jahnf/projecteur/tags - -FROM opensuse/leap:15.5 - -RUN mkdir /build -RUN zypper --non-interactive in --no-recommends \ - pkg-config \ - udev \ - gcc-c++ \ - tar \ - make \ - cmake \ - git \ - wget \ - libqt5-qtdeclarative-devel \ - rpmbuild \ - libqt5-linguist \ - libqt5-qtx11extras-devel \ - libusb-1_0-devel \ - libQt5DBus-devel - -RUN git config --global --add safe.directory /source diff --git a/docker/Dockerfile.ubuntu-18.04 b/docker/Dockerfile.ubuntu-18.04 deleted file mode 100644 index e3901096..00000000 --- a/docker/Dockerfile.ubuntu-18.04 +++ /dev/null @@ -1,23 +0,0 @@ -# Container for building the Projecteur package -# Images available at: https://hub.docker.com/r/jahnf/projecteur/tags - -FROM ubuntu:18.04 - -RUN apt-get update -RUN apt-get install -y --no-install-recommends \ - ca-certificates - -RUN apt-get install -y --no-install-recommends \ - g++ \ - make \ - cmake \ - udev \ - git \ - pkg-config \ - qtdeclarative5-dev \ - qttools5-dev-tools \ - qttools5-dev \ - qt5-default \ - libqt5x11extras5-dev \ - libusb-1.0-0-dev \ - && rm -rf /var/lib/apt/lists/* diff --git a/docker/Dockerfile.ubuntu-20.04 b/docker/Dockerfile.ubuntu-20.04 deleted file mode 100644 index 90c22633..00000000 --- a/docker/Dockerfile.ubuntu-20.04 +++ /dev/null @@ -1,22 +0,0 @@ -# Container for building the Projecteur package -# Images available at: https://hub.docker.com/r/jahnf/projecteur/tags - -FROM ubuntu:20.04 - -RUN apt-get update && mkdir /build -RUN DEBIAN_FRONTEND="noninteractive" \ - apt-get install -y --no-install-recommends \ - ca-certificates \ - g++ \ - make \ - cmake \ - udev \ - git \ - pkg-config \ - qtdeclarative5-dev \ - qttools5-dev-tools \ - qttools5-dev \ - qt5-default \ - libqt5x11extras5-dev \ - libusb-1.0-0-dev \ - && rm -rf /var/lib/apt/lists/* diff --git a/docker/Dockerfile.ubuntu-20.10 b/docker/Dockerfile.ubuntu-20.10 deleted file mode 100644 index d38ec0fe..00000000 --- a/docker/Dockerfile.ubuntu-20.10 +++ /dev/null @@ -1,22 +0,0 @@ -# Container for building the Projecteur package -# Images available at: https://hub.docker.com/r/jahnf/projecteur/tags - -FROM ubuntu:20.10 - -RUN apt-get update && mkdir /build -RUN DEBIAN_FRONTEND="noninteractive" \ - apt-get install -y --no-install-recommends \ - ca-certificates \ - g++ \ - make \ - cmake \ - udev \ - git \ - pkg-config \ - qtdeclarative5-dev \ - qttools5-dev-tools \ - qttools5-dev \ - qt5-default \ - libqt5x11extras5-dev \ - libusb-1.0-0-dev \ - && rm -rf /var/lib/apt/lists/* diff --git a/docker/Dockerfile.ubuntu-21.04 b/docker/Dockerfile.ubuntu-21.04 deleted file mode 100644 index 86f9871b..00000000 --- a/docker/Dockerfile.ubuntu-21.04 +++ /dev/null @@ -1,21 +0,0 @@ -# Container for building the Projecteur package -# Images available at: https://hub.docker.com/r/jahnf/projecteur/tags - -FROM ubuntu:21.04 - -RUN apt-get update && mkdir /build -RUN DEBIAN_FRONTEND="noninteractive" \ - apt-get install -y --no-install-recommends \ - ca-certificates \ - g++ \ - make \ - cmake \ - udev \ - git \ - pkg-config \ - qtdeclarative5-dev \ - qttools5-dev-tools \ - qttools5-dev \ - libqt5x11extras5-dev \ - libusb-1.0-0-dev \ - && rm -rf /var/lib/apt/lists/* diff --git a/docker/Dockerfile.ubuntu-22.04 b/docker/Dockerfile.ubuntu-22.04 deleted file mode 100644 index 638840ad..00000000 --- a/docker/Dockerfile.ubuntu-22.04 +++ /dev/null @@ -1,23 +0,0 @@ -# Container for building the Projecteur package -# Images available at: https://hub.docker.com/r/jahnf/projecteur/tags - -FROM ubuntu:22.04 - -RUN apt-get update && mkdir /build -RUN DEBIAN_FRONTEND="noninteractive" \ - apt-get install -y --no-install-recommends \ - ca-certificates \ - g++ \ - make \ - cmake \ - udev \ - git \ - pkg-config \ - qtdeclarative5-dev \ - qttools5-dev-tools \ - qttools5-dev \ - libqt5x11extras5-dev \ - libusb-1.0-0-dev \ - && rm -rf /var/lib/apt/lists/* - -RUN git config --global --add safe.directory /source diff --git a/docker/Dockerfile.ubuntu-23.04 b/docker/Dockerfile.ubuntu-23.04 deleted file mode 100644 index 5f0c20bf..00000000 --- a/docker/Dockerfile.ubuntu-23.04 +++ /dev/null @@ -1,23 +0,0 @@ -# Container for building the Projecteur package -# Images available at: https://hub.docker.com/r/jahnf/projecteur/tags - -FROM ubuntu:23.04 - -RUN apt-get update && mkdir /build -RUN DEBIAN_FRONTEND="noninteractive" \ - apt-get install -y --no-install-recommends \ - ca-certificates \ - g++ \ - make \ - cmake \ - udev \ - git \ - pkg-config \ - qtdeclarative5-dev \ - qttools5-dev-tools \ - qttools5-dev \ - libqt5x11extras5-dev \ - libusb-1.0-0-dev \ - && rm -rf /var/lib/apt/lists/* - -RUN git config --global --add safe.directory /source diff --git a/docker/README.md b/docker/README.md index 34e3d0aa..0480d853 100644 --- a/docker/README.md +++ b/docker/README.md @@ -1,12 +1,8 @@ -# Projecteur Dockerfiles +# Projecteur build container -Docker configuration files for build containers used in _Projecteur_ CI builds. +The maintained container targets the Qt 6 / KDE Plasma Wayland port on current +Arch Linux. -Example for creating an image: +```sh +docker build -f Dockerfile.archlinux --tag projecteur:archlinux . ``` -docker build -f Dockerfile.ubuntu-20.10 --tag jahnf/projecteur:ubuntu-20.10 . -``` - -Images used in the CI build can be found on docker hub: -https://hub.docker.com/r/jahnf/projecteur - diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD new file mode 100644 index 00000000..ae15dc23 --- /dev/null +++ b/packaging/arch/PKGBUILD @@ -0,0 +1,66 @@ +# Local development package for Projecteur. +# +# `just package` creates the two local sources below in a staging directory, +# updates their checksums, and invokes makepkg there. + +pkgname=projecteur +pkgver=0 +pkgrel=1 +pkgdesc='Virtual laser pointer for inertial pointer devices on KDE Plasma Wayland' +arch=('x86_64') +url='https://github.com/gbin/Projecteur' +license=('MIT') +depends=( + 'gcc-libs' + 'glibc' + 'kconfig>=6.7' + 'kconfigwidgets>=6.7' + 'kcoreaddons>=6.7' + 'kdbusaddons>=6.7' + 'kglobalaccel>=6.7' + 'ki18n>=6.7' + 'kpipewire>=6.7' + 'knotifications>=6.7' + 'kwidgetsaddons>=6.7' + 'kwindowsystem>=6.7' + 'kxmlgui>=6.7' + 'layer-shell-qt>=6.7' + 'libplasma>=6.7' + 'libglvnd' + 'qt6-base>=6.10' + 'qt6-declarative>=6.10' + 'qt6-wayland>=6.10' +) +makedepends=( + 'cmake>=3.20' + 'extra-cmake-modules>=6.7' + 'gettext' + 'qt6-shadertools>=6.10' +) +source=( + 'projecteur-local.tar.gz' + 'projecteur-pkgver' +) +sha256sums=( + 'SKIP' + 'SKIP' +) + +pkgver() { + cat "$srcdir/projecteur-pkgver" +} + +build() { + cmake -S projecteur-local -B build \ + -DCMAKE_BUILD_TYPE=None \ + -DCMAKE_INSTALL_PREFIX=/usr \ + -DCMAKE_INSTALL_UDEVRULESDIR=/usr/lib/udev/rules.d \ + -DPACKAGE_TARGETS=OFF + cmake --build build +} + +package() { + DESTDIR="$pkgdir" cmake --install build + install -Dm644 projecteur-local/LICENSE.md \ + "$pkgdir/usr/share/licenses/$pkgname/LICENSE" +} diff --git a/packaging/aur/.SRCINFO b/packaging/aur/.SRCINFO new file mode 100644 index 00000000..ef3e03fb --- /dev/null +++ b/packaging/aur/.SRCINFO @@ -0,0 +1,44 @@ +pkgbase = projecteur-git + pkgdesc = Virtual laser pointer for inertial pointer devices on KDE Plasma Wayland + pkgver = 1.0.0alpha.77.r723.8f187bf + pkgrel = 1 + url = https://github.com/gbin/Projecteur + arch = x86_64 + license = MIT + makedepends = cmake>=3.20 + makedepends = extra-cmake-modules>=6.7 + makedepends = gettext + makedepends = git + makedepends = qt6-shadertools>=6.10 + depends = glibc + depends = hicolor-icon-theme + depends = kcolorscheme>=6.7 + depends = kconfig>=6.7 + depends = kconfigwidgets>=6.7 + depends = kcoreaddons>=6.7 + depends = kdbusaddons>=6.7 + depends = kglobalaccel>=6.7 + depends = kguiaddons>=6.7 + depends = ki18n>=6.7 + depends = kirigami>=6.7 + depends = knotifications>=6.7 + depends = kpipewire>=6.7 + depends = kwidgetsaddons>=6.7 + depends = kwindowsystem>=6.7 + depends = kxmlgui>=6.7 + depends = layer-shell-qt>=6.7 + depends = libgcc + depends = libglvnd + depends = libplasma>=6.7 + depends = libstdc++ + depends = qt6-base>=6.10 + depends = qt6-declarative>=6.10 + depends = qt6-wayland>=6.10 + depends = udev + depends = wayland + provides = projecteur + conflicts = projecteur + source = projecteur::git+https://github.com/gbin/Projecteur.git#branch=develop + sha256sums = SKIP + +pkgname = projecteur-git diff --git a/packaging/aur/LICENSE b/packaging/aur/LICENSE new file mode 100644 index 00000000..5aec2581 --- /dev/null +++ b/packaging/aur/LICENSE @@ -0,0 +1,10 @@ +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. diff --git a/packaging/aur/PKGBUILD b/packaging/aur/PKGBUILD new file mode 100644 index 00000000..d2f22ebe --- /dev/null +++ b/packaging/aur/PKGBUILD @@ -0,0 +1,75 @@ +# Maintainer: dosssman +# Contributor: Guillaume Binet + +pkgname=projecteur-git +pkgver=1.0.0alpha.77.r723.8f187bf +pkgrel=1 +pkgdesc='Virtual laser pointer for inertial pointer devices on KDE Plasma Wayland' +arch=('x86_64') +url='https://github.com/gbin/Projecteur' +license=('MIT') +depends=( + 'glibc' + 'hicolor-icon-theme' + 'kcolorscheme>=6.7' + 'kconfig>=6.7' + 'kconfigwidgets>=6.7' + 'kcoreaddons>=6.7' + 'kdbusaddons>=6.7' + 'kglobalaccel>=6.7' + 'kguiaddons>=6.7' + 'ki18n>=6.7' + 'kirigami>=6.7' + 'knotifications>=6.7' + 'kpipewire>=6.7' + 'kwidgetsaddons>=6.7' + 'kwindowsystem>=6.7' + 'kxmlgui>=6.7' + 'layer-shell-qt>=6.7' + 'libgcc' + 'libglvnd' + 'libplasma>=6.7' + 'libstdc++' + 'qt6-base>=6.10' + 'qt6-declarative>=6.10' + 'qt6-wayland>=6.10' + 'udev' + 'wayland' +) +makedepends=( + 'cmake>=3.20' + 'extra-cmake-modules>=6.7' + 'gettext' + 'git' + 'qt6-shadertools>=6.10' +) +provides=('projecteur') +conflicts=('projecteur') +source=('projecteur::git+https://github.com/gbin/Projecteur.git#branch=develop') +sha256sums=('SKIP') + +prepare() { + cmake -S "$srcdir/projecteur" -B "$srcdir/build" \ + -DCMAKE_BUILD_TYPE=None \ + -DCMAKE_INSTALL_PREFIX=/usr \ + -DCMAKE_INSTALL_UDEVRULESDIR=/usr/lib/udev/rules.d \ + -DPACKAGE_TARGETS=OFF +} + +pkgver() { + cd "$srcdir/projecteur" + printf '%s.r%s.%s' \ + "$(<"$srcdir/build/version-string.archlinux")" \ + "$(git rev-list --count HEAD)" \ + "$(git rev-parse --short=7 HEAD)" +} + +build() { + cmake --build "$srcdir/build" +} + +package() { + DESTDIR="$pkgdir" cmake --install "$srcdir/build" + install -Dm644 "$srcdir/projecteur/LICENSE.md" \ + "$pkgdir/usr/share/licenses/$pkgname/LICENSE" +} diff --git a/plasma/CMakeLists.txt b/plasma/CMakeLists.txt new file mode 100644 index 00000000..2f9a25b5 --- /dev/null +++ b/plasma/CMakeLists.txt @@ -0,0 +1,30 @@ +if(NOT CMAKE_LIBRARY_OUTPUT_DIRECTORY) + set(CMAKE_LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin") +endif() + +qt_add_dbus_interface(PROJECTEUR_DBUS_INTERFACE_SOURCES + "${CMAKE_SOURCE_DIR}/src/org.projecteur.Projecteur.xml" + projecteurcontrolinterface +) + +plasma_add_applet(org.projecteur.Projecteur + QML_SOURCES + qml/CompactRepresentation.qml + qml/main.qml + qml/FullRepresentation.qml + CPP_SOURCES + projecteurapplet.cc + projecteurapplet.h + ${PROJECTEUR_DBUS_INTERFACE_SOURCES} +) + +target_include_directories(org.projecteur.Projecteur + PRIVATE "${CMAKE_CURRENT_BINARY_DIR}" +) + +target_link_libraries(org.projecteur.Projecteur + PRIVATE + Qt6::DBus + KF6::CoreAddons + Plasma::Plasma +) diff --git a/plasma/metadata.json b/plasma/metadata.json new file mode 100644 index 00000000..8cab53ed --- /dev/null +++ b/plasma/metadata.json @@ -0,0 +1,22 @@ +{ + "KPlugin": { + "Authors": [ + { + "Name": "Projecteur contributors" + } + ], + "Category": "Utilities", + "Description": "Quick controls for the Projecteur desktop spotlight", + "EnabledByDefault": true, + "Icon": "projecteur", + "License": "MIT", + "Name": "Projecteur", + "Version": "1.0", + "Website": "https://github.com/gbin/Projecteur" + }, + "KPackageStructure": "Plasma/Applet", + "X-Plasma-API-Minimum-Version": "6.0", + "X-Plasma-DBusActivationService": "org.projecteur.Projecteur", + "X-Plasma-NotificationArea": "true", + "X-Plasma-NotificationAreaCategory": "ApplicationStatus" +} diff --git a/plasma/projecteurapplet.cc b/plasma/projecteurapplet.cc new file mode 100644 index 00000000..1373903b --- /dev/null +++ b/plasma/projecteurapplet.cc @@ -0,0 +1,279 @@ +// This file is part of Projecteur - https://github.com/jahnf/projecteur +// - See LICENSE.md and README.md + +#include "projecteurapplet.h" + +#include "projecteurcontrolinterface.h" + +#include + +#include +#include +#include +#include + +namespace { +constexpr auto serviceName = "org.projecteur.Projecteur"; +constexpr auto objectPath = "/org/projecteur/Projecteur/Control"; +} + +ProjecteurApplet::ProjecteurApplet(QObject* parent, const KPluginMetaData& data, + const QVariantList& args) + : Plasma::Applet(parent, data, args) + , m_serviceWatcher(new QDBusServiceWatcher( + QString::fromLatin1(serviceName), QDBusConnection::sessionBus(), + QDBusServiceWatcher::WatchForRegistration | QDBusServiceWatcher::WatchForUnregistration, + this)) +{ + connect(m_serviceWatcher, &QDBusServiceWatcher::serviceRegistered, + this, &ProjecteurApplet::serviceRegistered); + connect(m_serviceWatcher, &QDBusServiceWatcher::serviceUnregistered, + this, &ProjecteurApplet::serviceUnregistered); + + const auto bus = QDBusConnection::sessionBus(); + const auto registered = bus.interface()->isServiceRegistered(QString::fromLatin1(serviceName)); + if (registered.isValid() && registered.value()) { + serviceRegistered(QString::fromLatin1(serviceName)); + } +} + +void ProjecteurApplet::setOverlayEnabled(bool enabled) +{ + if (m_interface) { m_interface->SetOverlayEnabled(enabled); } +} + +void ProjecteurApplet::setSpotlightActive(bool active) +{ + if (m_interface) { m_interface->SetSpotlightActive(active); } +} + +void ProjecteurApplet::loadPreset(const QString& preset) +{ + if (m_interface) { m_interface->LoadPreset(preset); } +} + +void ProjecteurApplet::setTimerEnabled(bool enabled) +{ + if (m_interface) { m_interface->SetTimerEnabled(enabled); } +} + +void ProjecteurApplet::startTimer() +{ + if (m_interface) { m_interface->StartTimer(); } +} + +void ProjecteurApplet::restartTimer() +{ + if (m_interface) { m_interface->RestartTimer(); } +} + +void ProjecteurApplet::resetTimer() +{ + if (m_interface) { m_interface->ResetTimer(); } +} + +void ProjecteurApplet::setTimerDurationSeconds(int seconds) +{ + if (m_interface) { m_interface->SetTimerDurationSeconds(seconds); } +} + +void ProjecteurApplet::showPreferences() +{ + if (m_interface) { m_interface->ShowPreferences(); } +} + +void ProjecteurApplet::showAbout() +{ + if (m_interface) { m_interface->ShowAbout(); } +} + +void ProjecteurApplet::quitProjecteur() +{ + if (m_interface) { m_interface->Quit(); } +} + +void ProjecteurApplet::serviceRegistered(const QString& service) +{ + if (service != QString::fromLatin1(serviceName)) { return; } + createInterface(); + if (!m_serviceAvailable) { + m_serviceAvailable = true; + emit serviceAvailableChanged(); + } + QTimer::singleShot(0, this, &ProjecteurApplet::refresh); +} + +void ProjecteurApplet::serviceUnregistered(const QString& service) +{ + if (service != QString::fromLatin1(serviceName)) { return; } + delete m_interface; + m_interface = nullptr; + if (m_serviceAvailable) { + m_serviceAvailable = false; + emit serviceAvailableChanged(); + } + resetState(); +} + +void ProjecteurApplet::remoteOverlayEnabledChanged(bool enabled) +{ + if (m_overlayEnabled == enabled) { return; } + m_overlayEnabled = enabled; + emit overlayEnabledChanged(); +} + +void ProjecteurApplet::remoteSpotlightActiveChanged(bool active) +{ + if (m_spotlightActive == active) { return; } + m_spotlightActive = active; + emit spotlightActiveChanged(); +} + +void ProjecteurApplet::remoteConnectedDevicesChanged(const QStringList& devices) +{ + if (m_connectedDevices == devices) { return; } + m_connectedDevices = devices; + emit connectedDevicesChanged(); +} + +void ProjecteurApplet::remoteConnectedDeviceBatteryLevelsChanged(const QList& levels) +{ + if (m_connectedDeviceBatteryLevels == levels) { return; } + m_connectedDeviceBatteryLevels = levels; + emit connectedDeviceBatteryLevelsChanged(); +} + +void ProjecteurApplet::remoteConnectedDeviceBatteryStatusesChanged(const QStringList& statuses) +{ + if (m_connectedDeviceBatteryStatuses == statuses) { return; } + m_connectedDeviceBatteryStatuses = statuses; + emit connectedDeviceBatteryStatusesChanged(); +} + +void ProjecteurApplet::remotePresetsChanged(const QStringList& presets) +{ + if (m_presets == presets) { return; } + m_presets = presets; + emit presetsChanged(); +} + +void ProjecteurApplet::remoteCurrentPresetChanged(const QString& preset) +{ + if (m_currentPreset == preset) { return; } + m_currentPreset = preset; + emit currentPresetChanged(); +} + +void ProjecteurApplet::remoteTimerEnabledChanged(bool enabled) +{ + if (m_timerEnabled == enabled) { return; } + m_timerEnabled = enabled; + emit timerEnabledChanged(); +} + +void ProjecteurApplet::remoteTimerStateChanged(const QString& state) +{ + if (m_timerState == state) { return; } + m_timerState = state; + emit timerStateChanged(); +} + +void ProjecteurApplet::remoteTimerDurationSecondsChanged(int seconds) +{ + if (m_timerDurationSeconds == seconds) { return; } + m_timerDurationSeconds = seconds; + emit timerDurationSecondsChanged(); +} + +void ProjecteurApplet::remoteTimerRemainingSecondsChanged(int seconds) +{ + if (m_timerRemainingSeconds == seconds) { return; } + m_timerRemainingSeconds = seconds; + emit timerRemainingSecondsChanged(); +} + +void ProjecteurApplet::createInterface() +{ + delete m_interface; + m_interface = new OrgProjecteurProjecteurInterface( + QString::fromLatin1(serviceName), QString::fromLatin1(objectPath), + QDBusConnection::sessionBus(), this); + + connect(m_interface, &OrgProjecteurProjecteurInterface::overlayEnabledChanged, + this, &ProjecteurApplet::remoteOverlayEnabledChanged); + connect(m_interface, &OrgProjecteurProjecteurInterface::spotlightActiveChanged, + this, &ProjecteurApplet::remoteSpotlightActiveChanged); + connect(m_interface, &OrgProjecteurProjecteurInterface::connectedDevicesChanged, + this, &ProjecteurApplet::remoteConnectedDevicesChanged); + connect(m_interface, + &OrgProjecteurProjecteurInterface::connectedDeviceBatteryLevelsChanged, + this, &ProjecteurApplet::remoteConnectedDeviceBatteryLevelsChanged); + connect(m_interface, + &OrgProjecteurProjecteurInterface::connectedDeviceBatteryStatusesChanged, + this, &ProjecteurApplet::remoteConnectedDeviceBatteryStatusesChanged); + connect(m_interface, &OrgProjecteurProjecteurInterface::presetsChanged, + this, &ProjecteurApplet::remotePresetsChanged); + connect(m_interface, &OrgProjecteurProjecteurInterface::currentPresetChanged, + this, &ProjecteurApplet::remoteCurrentPresetChanged); + connect(m_interface, &OrgProjecteurProjecteurInterface::timerEnabledChanged, + this, &ProjecteurApplet::remoteTimerEnabledChanged); + connect(m_interface, &OrgProjecteurProjecteurInterface::timerStateChanged, + this, &ProjecteurApplet::remoteTimerStateChanged); + connect(m_interface, &OrgProjecteurProjecteurInterface::timerDurationSecondsChanged, + this, &ProjecteurApplet::remoteTimerDurationSecondsChanged); + connect(m_interface, &OrgProjecteurProjecteurInterface::timerRemainingSecondsChanged, + this, &ProjecteurApplet::remoteTimerRemainingSecondsChanged); +} + +void ProjecteurApplet::refresh() +{ + if (!m_interface || !m_interface->isValid()) { return; } + + const bool trayVisible = m_interface->trayVisible(); + if (m_trayVisible != trayVisible) { + m_trayVisible = trayVisible; + emit trayVisibleChanged(); + } + remoteOverlayEnabledChanged(m_interface->overlayEnabled()); + remoteSpotlightActiveChanged(m_interface->spotlightActive()); + remoteConnectedDevicesChanged(m_interface->connectedDevices()); + remoteConnectedDeviceBatteryLevelsChanged(m_interface->connectedDeviceBatteryLevels()); + remoteConnectedDeviceBatteryStatusesChanged(m_interface->connectedDeviceBatteryStatuses()); + remotePresetsChanged(m_interface->presets()); + remoteCurrentPresetChanged(m_interface->currentPreset()); + if (!m_timerAvailable) { + m_timerAvailable = true; + emit timerAvailableChanged(); + } + remoteTimerEnabledChanged(m_interface->timerEnabled()); + remoteTimerStateChanged(m_interface->timerState()); + remoteTimerDurationSecondsChanged(m_interface->timerDurationSeconds()); + remoteTimerRemainingSecondsChanged(m_interface->timerRemainingSeconds()); +} + +void ProjecteurApplet::resetState() +{ + if (!m_trayVisible) { + m_trayVisible = true; + emit trayVisibleChanged(); + } + remoteOverlayEnabledChanged(true); + remoteSpotlightActiveChanged(false); + remoteConnectedDevicesChanged({}); + remoteConnectedDeviceBatteryLevelsChanged({}); + remoteConnectedDeviceBatteryStatusesChanged({}); + remotePresetsChanged({}); + remoteCurrentPresetChanged({}); + if (m_timerAvailable) { + m_timerAvailable = false; + emit timerAvailableChanged(); + } + remoteTimerEnabledChanged(false); + remoteTimerStateChanged(QStringLiteral("idle")); + remoteTimerDurationSecondsChanged(15 * 60); + remoteTimerRemainingSecondsChanged(15 * 60); +} + +K_PLUGIN_CLASS_WITH_JSON(ProjecteurApplet, "metadata.json") + +#include "projecteurapplet.moc" diff --git a/plasma/projecteurapplet.h b/plasma/projecteurapplet.h new file mode 100644 index 00000000..3e9471c4 --- /dev/null +++ b/plasma/projecteurapplet.h @@ -0,0 +1,115 @@ +// This file is part of Projecteur - https://github.com/jahnf/projecteur +// - See LICENSE.md and README.md +#pragma once + +#include + +#include +#include + +class QDBusServiceWatcher; +class OrgProjecteurProjecteurInterface; + +class ProjecteurApplet : public Plasma::Applet +{ + Q_OBJECT + Q_PROPERTY(bool serviceAvailable READ serviceAvailable NOTIFY serviceAvailableChanged) + Q_PROPERTY(bool timerAvailable READ timerAvailable NOTIFY timerAvailableChanged) + Q_PROPERTY(bool trayVisible READ trayVisible NOTIFY trayVisibleChanged) + Q_PROPERTY(bool overlayEnabled READ overlayEnabled NOTIFY overlayEnabledChanged) + Q_PROPERTY(bool spotlightActive READ spotlightActive NOTIFY spotlightActiveChanged) + Q_PROPERTY(QStringList connectedDevices READ connectedDevices NOTIFY connectedDevicesChanged) + Q_PROPERTY(QList connectedDeviceBatteryLevels READ connectedDeviceBatteryLevels + NOTIFY connectedDeviceBatteryLevelsChanged) + Q_PROPERTY(QStringList connectedDeviceBatteryStatuses READ connectedDeviceBatteryStatuses + NOTIFY connectedDeviceBatteryStatusesChanged) + Q_PROPERTY(QStringList presets READ presets NOTIFY presetsChanged) + Q_PROPERTY(QString currentPreset READ currentPreset NOTIFY currentPresetChanged) + Q_PROPERTY(bool timerEnabled READ timerEnabled NOTIFY timerEnabledChanged) + Q_PROPERTY(QString timerState READ timerState NOTIFY timerStateChanged) + Q_PROPERTY(int timerDurationSeconds READ timerDurationSeconds NOTIFY timerDurationSecondsChanged) + Q_PROPERTY(int timerRemainingSeconds READ timerRemainingSeconds NOTIFY timerRemainingSecondsChanged) + +public: + ProjecteurApplet(QObject* parent, const KPluginMetaData& data, const QVariantList& args); + + bool serviceAvailable() const { return m_serviceAvailable; } + bool timerAvailable() const { return m_timerAvailable; } + bool trayVisible() const { return m_trayVisible; } + bool overlayEnabled() const { return m_overlayEnabled; } + bool spotlightActive() const { return m_spotlightActive; } + QStringList connectedDevices() const { return m_connectedDevices; } + QList connectedDeviceBatteryLevels() const { return m_connectedDeviceBatteryLevels; } + QStringList connectedDeviceBatteryStatuses() const { return m_connectedDeviceBatteryStatuses; } + QStringList presets() const { return m_presets; } + QString currentPreset() const { return m_currentPreset; } + bool timerEnabled() const { return m_timerEnabled; } + QString timerState() const { return m_timerState; } + int timerDurationSeconds() const { return m_timerDurationSeconds; } + int timerRemainingSeconds() const { return m_timerRemainingSeconds; } + + Q_INVOKABLE void setOverlayEnabled(bool enabled); + Q_INVOKABLE void setSpotlightActive(bool active); + Q_INVOKABLE void loadPreset(const QString& preset); + Q_INVOKABLE void setTimerEnabled(bool enabled); + Q_INVOKABLE void startTimer(); + Q_INVOKABLE void restartTimer(); + Q_INVOKABLE void resetTimer(); + Q_INVOKABLE void setTimerDurationSeconds(int seconds); + Q_INVOKABLE void showPreferences(); + Q_INVOKABLE void showAbout(); + Q_INVOKABLE void quitProjecteur(); + +signals: + void serviceAvailableChanged(); + void timerAvailableChanged(); + void trayVisibleChanged(); + void overlayEnabledChanged(); + void spotlightActiveChanged(); + void connectedDevicesChanged(); + void connectedDeviceBatteryLevelsChanged(); + void connectedDeviceBatteryStatusesChanged(); + void presetsChanged(); + void currentPresetChanged(); + void timerEnabledChanged(); + void timerStateChanged(); + void timerDurationSecondsChanged(); + void timerRemainingSecondsChanged(); + +private slots: + void serviceRegistered(const QString& service); + void serviceUnregistered(const QString& service); + void remoteOverlayEnabledChanged(bool enabled); + void remoteSpotlightActiveChanged(bool active); + void remoteConnectedDevicesChanged(const QStringList& devices); + void remoteConnectedDeviceBatteryLevelsChanged(const QList& levels); + void remoteConnectedDeviceBatteryStatusesChanged(const QStringList& statuses); + void remotePresetsChanged(const QStringList& presets); + void remoteCurrentPresetChanged(const QString& preset); + void remoteTimerEnabledChanged(bool enabled); + void remoteTimerStateChanged(const QString& state); + void remoteTimerDurationSecondsChanged(int seconds); + void remoteTimerRemainingSecondsChanged(int seconds); + +private: + void createInterface(); + void refresh(); + void resetState(); + + QDBusServiceWatcher* m_serviceWatcher = nullptr; + OrgProjecteurProjecteurInterface* m_interface = nullptr; + bool m_serviceAvailable = false; + bool m_timerAvailable = false; + bool m_trayVisible = true; + bool m_overlayEnabled = true; + bool m_spotlightActive = false; + QStringList m_connectedDevices; + QList m_connectedDeviceBatteryLevels; + QStringList m_connectedDeviceBatteryStatuses; + QStringList m_presets; + QString m_currentPreset; + bool m_timerEnabled = false; + QString m_timerState = QStringLiteral("idle"); + int m_timerDurationSeconds = 15 * 60; + int m_timerRemainingSeconds = 15 * 60; +}; diff --git a/plasma/qml/CompactRepresentation.qml b/plasma/qml/CompactRepresentation.qml new file mode 100644 index 00000000..cd835179 --- /dev/null +++ b/plasma/qml/CompactRepresentation.qml @@ -0,0 +1,109 @@ +// This file is part of Projecteur - https://github.com/jahnf/projecteur +// - See LICENSE.md and README.md + +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import org.kde.kirigami as Kirigami +import org.kde.plasma.core as PlasmaCore +import org.kde.plasma.plasmoid + +Kirigami.Icon { + id: compact + + required property var backend + required property PlasmoidItem plasmoidItem + + readonly property bool timerUrgent: backend + && backend.timerState === "running" + && backend.timerRemainingSeconds < 60 + readonly property bool timerWarning: backend + && backend.timerState === "running" + && backend.timerRemainingSeconds <= 5 * 60 + readonly property color badgeAccentColor: { + if (backend && (backend.timerState === "completed" || compact.timerUrgent)) + return Kirigami.Theme.negativeTextColor; + if (compact.timerWarning) + return Kirigami.Theme.neutralTextColor; + return Kirigami.Theme.activeTextColor; + } + + Layout.minimumWidth: { + if (Plasmoid.formFactor === PlasmaCore.Types.Horizontal) + return height; + return 0; + } + Layout.minimumHeight: { + if (Plasmoid.formFactor === PlasmaCore.Types.Vertical) + return width; + return 0; + } + + source: Plasmoid.icon || "projecteur" + active: pointerArea.containsMouse + activeFocusOnTab: true + + Accessible.name: Plasmoid.title + Accessible.description: plasmoidItem.toolTipSubText + Accessible.role: Accessible.Button + + Keys.onPressed: event => { + switch (event.key) { + case Qt.Key_Space: + case Qt.Key_Enter: + case Qt.Key_Return: + case Qt.Key_Select: + Plasmoid.activated(); + event.accepted = true; + break; + } + } + + MouseArea { + id: pointerArea + + property bool wasExpanded: false + + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.MiddleButton + hoverEnabled: true + + onPressed: wasExpanded = compact.plasmoidItem.expanded + onClicked: mouse => { + if (mouse.button === Qt.MiddleButton) + Plasmoid.secondaryActivated(); + else + compact.plasmoidItem.expanded = !wasExpanded; + } + } + + Rectangle { + id: timerBadge + + readonly property int badgePadding: Math.max(2, Math.round(Kirigami.Units.smallSpacing / 2)) + + visible: compact.plasmoidItem.badgeText.length > 0 + z: 1 + x: width >= compact.width ? (compact.width - width) / 2 : compact.width - width + y: compact.height - height + implicitWidth: Math.max(implicitHeight, badgeLabel.implicitWidth + badgePadding * 2) + implicitHeight: badgeLabel.implicitHeight + 2 + radius: height / 2 + color: Kirigami.ColorUtils.tintWithAlpha( + Kirigami.Theme.backgroundColor, compact.badgeAccentColor, 0.35) + border.width: 1 + border.color: compact.badgeAccentColor + + Text { + id: badgeLabel + + anchors.centerIn: parent + text: compact.plasmoidItem.badgeText + color: Kirigami.Theme.textColor + font.family: Kirigami.Theme.smallFont.family + font.pointSize: Kirigami.Theme.smallFont.pointSize + font.bold: true + } + } +} diff --git a/plasma/qml/FullRepresentation.qml b/plasma/qml/FullRepresentation.qml new file mode 100644 index 00000000..9a875995 --- /dev/null +++ b/plasma/qml/FullRepresentation.qml @@ -0,0 +1,401 @@ +// This file is part of Projecteur - https://github.com/jahnf/projecteur +// - See LICENSE.md and README.md + +import QtQuick +import QtQuick.Layouts +import org.kde.kirigami as Kirigami +import org.kde.plasma.components as PlasmaComponents3 +import org.kde.plasma.core as PlasmaCore +import org.kde.plasma.extras as PlasmaExtras +import org.kde.plasma.plasmoid + +PlasmaExtras.Representation { + id: root + + required property var backend + required property PlasmoidItem plasmoidItem + + function batteryLevel(index) { + if (!root.backend || index >= root.backend.connectedDeviceBatteryLevels.length) + return -1; + + return root.backend.connectedDeviceBatteryLevels[index]; + } + + function batteryStatus(index) { + if (!root.backend || index >= root.backend.connectedDeviceBatteryStatuses.length) + return ""; + + return root.backend.connectedDeviceBatteryStatuses[index]; + } + + function batteryIconName(index) { + const level = batteryLevel(index); + const status = batteryStatus(index); + if (status === "invalid-battery" || status === "thermal-error" || status === "charging-error") + return "dialog-warning-symbolic"; + + if (level < 0) + return ""; + + const charging = status === "charging" || status === "almost-full" || status === "slow-charging"; + const suffix = charging ? "-charging-symbolic" : "-symbolic"; + if (level <= 5) + return "battery-empty" + suffix; + if (level <= 10) + return "battery-caution" + suffix; + if (level <= 20) + return "battery-low" + suffix; + + const roundedLevel = Math.min(100, Math.max(0, Math.round(level / 10) * 10)); + return "battery-" + roundedLevel.toString().padStart(3, "0") + suffix; + } + + function batteryToolTip(index) { + const level = batteryLevel(index); + const status = batteryStatus(index); + if (status === "invalid-battery") + return i18n("Battery error"); + if (status === "thermal-error") + return i18n("Battery temperature error"); + if (status === "charging-error") + return i18n("Battery charging error"); + if (level < 0) + return ""; + + let state = ""; + if (status === "charging") + state = i18n("Charging"); + else if (status === "almost-full") + state = i18n("Almost full"); + else if (status === "full") + state = i18n("Full"); + else if (status === "slow-charging") + state = i18n("Charging slowly"); + else if (status === "discharging") + state = i18n("Discharging"); + + return state.length > 0 ? i18n("Battery: %1% (%2)", level, state) + : i18n("Battery: %1%", level); + } + + function formatTimer(seconds) { + const safeSeconds = Math.max(0, seconds); + const hours = Math.floor(safeSeconds / 3600); + const minutes = Math.floor((safeSeconds % 3600) / 60); + const remainingSeconds = safeSeconds % 60; + const mm = minutes.toString().padStart(2, "0"); + const ss = remainingSeconds.toString().padStart(2, "0"); + return hours > 0 ? hours.toString() + ":" + mm + ":" + ss : mm + ":" + ss; + } + + implicitWidth: Kirigami.Units.gridUnit * 22 + implicitHeight: content.implicitHeight + header.implicitHeight + focus: true + collapseMarginsHint: true + + ColumnLayout { + id: content + + spacing: Kirigami.Units.largeSpacing + + anchors { + left: parent.left + right: parent.right + top: parent.top + margins: Kirigami.Units.largeSpacing + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + + RowLayout { + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + + PlasmaComponents3.Label { + text: i18n("Connected presenters") + font.bold: true + } + + Kirigami.Separator { + Layout.fillWidth: true + } + } + + PlasmaComponents3.Label { + Layout.fillWidth: true + visible: !root.backend || root.backend.connectedDevices.length === 0 + text: i18n("No compatible presenter connected") + opacity: 0.7 + wrapMode: Text.WordWrap + } + + Repeater { + model: root.backend ? root.backend.connectedDevices : [] + + delegate: RowLayout { + required property int index + required property string modelData + + Layout.fillWidth: true + Layout.leftMargin: Kirigami.Units.smallSpacing + Layout.rightMargin: Kirigami.Units.smallSpacing + spacing: Kirigami.Units.smallSpacing + + Kirigami.Icon { + source: "input-mouse-symbolic" + implicitWidth: Kirigami.Units.iconSizes.medium + implicitHeight: implicitWidth + } + + PlasmaComponents3.Label { + Layout.fillWidth: true + text: modelData + elide: Text.ElideRight + } + + Kirigami.Icon { + id: batteryIcon + + readonly property string iconName: root.batteryIconName(index) + readonly property string toolTipText: root.batteryToolTip(index) + + visible: iconName.length > 0 + source: iconName + implicitWidth: Kirigami.Units.iconSizes.smallMedium + implicitHeight: implicitWidth + Accessible.name: toolTipText + + MouseArea { + id: batteryHover + + anchors.fill: parent + acceptedButtons: Qt.NoButton + hoverEnabled: true + } + + PlasmaComponents3.ToolTip { + text: batteryIcon.toolTipText + visible: batteryHover.containsMouse && text.length > 0 + } + } + } + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + + RowLayout { + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + + PlasmaComponents3.CheckBox { + text: i18n("Presentation timer") + font.bold: true + checked: root.backend && root.backend.timerEnabled + enabled: root.backend && root.backend.serviceAvailable + && root.backend.timerAvailable + onClicked: root.backend.setTimerEnabled(checked) + } + + Kirigami.Separator { + Layout.fillWidth: true + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + enabled: !root.backend || !root.backend.timerAvailable + || root.backend.timerEnabled + + PlasmaComponents3.Label { + Layout.fillWidth: true + visible: root.backend && root.backend.serviceAvailable && !root.backend.timerAvailable + text: i18n("Restart Projecteur to load the timer controls.") + wrapMode: Text.WordWrap + } + + PlasmaComponents3.Label { + Layout.fillWidth: true + visible: root.backend && root.backend.timerAvailable + && root.backend.timerState === "idle" + text: i18n("Ready — starts on the next presenter button press") + opacity: 0.8 + wrapMode: Text.WordWrap + } + + PlasmaComponents3.Label { + Layout.fillWidth: true + visible: root.backend && root.backend.timerAvailable + && root.backend.timerState !== "idle" + horizontalAlignment: Text.AlignHCenter + text: root.backend && root.backend.timerState === "completed" + ? i18n("Time’s up") + : root.formatTimer(root.backend ? root.backend.timerRemainingSeconds : 0) + font.pointSize: Kirigami.Theme.defaultFont.pointSize * 1.8 + font.bold: true + } + + RowLayout { + Layout.fillWidth: true + visible: root.backend && root.backend.timerAvailable + && root.backend.timerState === "idle" + spacing: Kirigami.Units.smallSpacing + + PlasmaComponents3.Label { + text: i18n("Duration:") + } + + PlasmaComponents3.SpinBox { + Layout.fillWidth: true + from: 1 + to: 180 + editable: true + value: root.backend ? Math.round(root.backend.timerDurationSeconds / 60) : 15 + enabled: root.backend && root.backend.serviceAvailable + onValueModified: root.backend.setTimerDurationSeconds(value * 60) + } + + PlasmaComponents3.Label { + text: i18n("min") + } + + PlasmaComponents3.Button { + enabled: root.backend && root.backend.serviceAvailable + text: i18n("Start now") + icon.name: "media-playback-start-symbolic" + onClicked: root.backend.startTimer() + } + } + + RowLayout { + Layout.fillWidth: true + visible: root.backend && root.backend.timerAvailable + && root.backend.timerState !== "idle" + spacing: Kirigami.Units.smallSpacing + + PlasmaComponents3.Button { + Layout.fillWidth: true + enabled: root.backend && root.backend.serviceAvailable + text: i18n("Restart") + icon.name: "view-refresh-symbolic" + onClicked: root.backend.restartTimer() + } + + PlasmaComponents3.Button { + Layout.fillWidth: true + enabled: root.backend && root.backend.serviceAvailable + text: i18n("Reset") + icon.name: "edit-clear-symbolic" + onClicked: root.backend.resetTimer() + } + } + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + + RowLayout { + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + + PlasmaComponents3.Label { + text: i18n("Spotlight") + font.bold: true + } + + Kirigami.Separator { + Layout.fillWidth: true + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + + RowLayout { + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + + PlasmaComponents3.Label { + text: i18n("Preset:") + } + + PlasmaComponents3.ComboBox { + id: presetCombo + + Layout.fillWidth: true + enabled: root.backend && root.backend.serviceAvailable + model: [i18n("Current Settings")].concat(root.backend ? root.backend.presets : []) + currentIndex: { + if (!root.backend || root.backend.currentPreset.length === 0) + return 0; + + const index = root.backend.presets.indexOf(root.backend.currentPreset); + return index < 0 ? 0 : index + 1; + } + onActivated: (index) => { + if (index > 0) + root.backend.loadPreset(root.backend.presets[index - 1]); + } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + + PlasmaComponents3.Button { + Layout.fillWidth: true + enabled: root.backend && root.backend.serviceAvailable + text: root.backend && root.backend.spotlightActive ? i18n("Hide Spotlight") : i18n("Test Spotlight") + icon.name: root.backend && root.backend.spotlightActive ? "visibility-hidden-symbolic" : "visibility-symbolic" + onClicked: root.backend.setSpotlightActive(!root.backend.spotlightActive) + } + + PlasmaComponents3.Button { + Layout.fillWidth: true + enabled: root.backend && root.backend.serviceAvailable + text: i18n("Preferences…") + icon.name: "configure-symbolic" + onClicked: { + root.plasmoidItem.expanded = false; + root.backend.showPreferences(); + } + } + } + } + } + + } + + header: PlasmaExtras.PlasmoidHeading { + id: header + + contentItem: RowLayout { + spacing: Kirigami.Units.smallSpacing + + PlasmaComponents3.Switch { + text: i18n("Enable Spotlight") + icon.name: "projecteur" + checked: root.backend ? root.backend.overlayEnabled : false + enabled: root.backend && root.backend.serviceAvailable + onToggled: root.backend.setOverlayEnabled(checked) + } + + Item { + Layout.fillWidth: true + } + + } + + } + +} diff --git a/plasma/qml/main.qml b/plasma/qml/main.qml new file mode 100644 index 00000000..dcd1c29f --- /dev/null +++ b/plasma/qml/main.qml @@ -0,0 +1,103 @@ +// This file is part of Projecteur - https://github.com/jahnf/projecteur +// - See LICENSE.md and README.md + +pragma ComponentBehavior: Bound + +import QtQuick +import org.kde.plasma.core as PlasmaCore +import org.kde.plasma.plasmoid + +PlasmoidItem { + id: root + + readonly property var backend: Plasmoid + + function formatTimer(seconds) { + const safeSeconds = Math.max(0, seconds); + const hours = Math.floor(safeSeconds / 3600); + const minutes = Math.floor((safeSeconds % 3600) / 60); + const remainingSeconds = safeSeconds % 60; + const mm = minutes.toString().padStart(2, "0"); + const ss = remainingSeconds.toString().padStart(2, "0"); + return hours > 0 ? hours.toString() + ":" + mm + ":" + ss : mm + ":" + ss; + } + + switchWidth: 320 + switchHeight: 320 + activationTogglesExpanded: true + hideOnWindowDeactivate: true + Plasmoid.icon: "projecteur" + badgeText: { + if (!backend || !backend.serviceAvailable || !backend.timerAvailable + || !backend.timerEnabled) + return ""; + + if (backend.timerState === "completed") + return "!"; + + if (backend.timerState === "running") { + const seconds = Math.max(0, backend.timerRemainingSeconds); + return seconds >= 60 ? Math.ceil(seconds / 60).toString() : seconds.toString(); + } + + return "…"; + } + Plasmoid.status: { + if (!backend || !backend.serviceAvailable || !backend.trayVisible) + return PlasmaCore.Types.HiddenStatus; + if (backend.timerAvailable && backend.timerEnabled + && backend.timerState === "completed") + return PlasmaCore.Types.NeedsAttentionStatus; + return PlasmaCore.Types.ActiveStatus; + } + toolTipMainText: i18n("Projecteur") + toolTipSubText: { + if (!backend || !backend.serviceAvailable) + return i18n("Projecteur is not running"); + + if (backend.timerAvailable && backend.timerEnabled + && backend.timerState === "running") + return i18n("Presentation timer: %1 remaining", root.formatTimer(backend.timerRemainingSeconds)); + + if (backend.timerAvailable && backend.timerEnabled + && backend.timerState === "completed") + return i18n("Presentation timer finished"); + + if (backend.timerAvailable && backend.timerEnabled + && backend.timerState === "idle") + return i18n("Timer ready: %1 — starts on the next presenter button press", + root.formatTimer(backend.timerDurationSeconds)); + + if (backend.connectedDevices.length === 0) + return i18n("No presenter connected"); + + return i18np("%1 connected presenter", "%1 connected presenters", backend.connectedDevices.length); + } + Plasmoid.onActivated: root.expanded = !root.expanded + + compactRepresentation: CompactRepresentation { + backend: root.backend + plasmoidItem: root + } + + Plasmoid.contextualActions: [ + PlasmaCore.Action { + text: i18n("About Projecteur") + icon.name: "help-about-symbolic" + enabled: backend && backend.serviceAvailable + onTriggered: backend.showAbout() + }, + PlasmaCore.Action { + text: i18n("Quit Projecteur") + icon.name: "application-exit-symbolic" + enabled: backend && backend.serviceAvailable + onTriggered: backend.quitProjecteur() + } + ] + + fullRepresentation: FullRepresentation { + backend: root.backend + plasmoidItem: root + } + +} diff --git a/po/.gitkeep b/po/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/po/.gitkeep @@ -0,0 +1 @@ + diff --git a/projecteur.notifyrc b/projecteur.notifyrc new file mode 100644 index 00000000..40113088 --- /dev/null +++ b/projecteur.notifyrc @@ -0,0 +1,35 @@ +[Global] +IconName=projecteur +DesktopEntry=org.projecteur.Projecteur +Name=Projecteur +Comment=KDE Plasma spotlight for presenter devices + +[Event/presentationTimerFinished] +Name=Presentation timer finished +Comment=The configured presentation time has elapsed +Action=Popup + +[Event/presenterConnected] +Name=Presenter connected +Comment=A supported presenter became available +Action=Popup + +[Event/presenterDisconnected] +Name=Presenter disconnected +Comment=A connected presenter became unavailable +Action=Popup + +[Event/presenterBatteryLow] +Name=Presenter battery low +Comment=A presenter battery has reached a low level +Action=Popup + +[Event/presenterBatteryError] +Name=Presenter battery error +Comment=A presenter reported a battery or charging problem +Action=Popup + +[Event/deviceAccessError] +Name=Presenter access failed +Comment=Projecteur could not access a supported presenter device +Action=Popup diff --git a/protocols/zkde-screencast-unstable-v1.xml b/protocols/zkde-screencast-unstable-v1.xml new file mode 100644 index 00000000..2e6be64b --- /dev/null +++ b/protocols/zkde-screencast-unstable-v1.xml @@ -0,0 +1,78 @@ + + + + + SPDX-License-Identifier: LGPL-2.1-or-later + ]]> + + + + This protocol is a Plasma implementation detail. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/qml/main-qt6.qml b/qml/main-qt6.qml deleted file mode 100644 index 085d0d1f..00000000 --- a/qml/main-qt6.qml +++ /dev/null @@ -1,213 +0,0 @@ -// This file is part of Projecteur - https://github.com/jahnf/projecteur - See LICENSE.md and README.md -import QtQuick 2.3 -import QtQuick.Window 2.2 - -import Qt5Compat.GraphicalEffects - -import Projecteur.Utils 1.0 as Utils - -Window { - id: mainWindow - property var screenId: -1 - readonly property bool spotOnCurrentWindow: ProjecteurApp.currentSpotScreen === screenId - property alias desktopPixmap: desktopImage.pixmap - - width: 300; height: 200 - - flags: Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.SplashScreen - - color: "transparent" - - readonly property double diagonal: Math.sqrt(Math.pow(Math.max(width, height),2)*2) - - Item { - id: rotationItem - anchors.centerIn: parent - width: rotation === 0 ? mainWindow.width : mainWindow.diagonal; - height: rotation === 0 ? mainWindow.height : width - rotation: Settings.spotRotationAllowed ? Settings.spotRotation : 0 - - opacity: ProjecteurApp.overlayVisible ? 1.0 : 0.0 - Behavior on opacity { PropertyAnimation { easing.type: Easing.OutQuad } } - - Item { - id: desktopItem - anchors.centerIn: centerRect - visible: false; enabled: false; clip: true - scale: Settings.zoomFactor - width: centerRect.width / scale; height: centerRect.height / scale - - Utils.Image { - id: desktopImage - smooth: rotation == 0 ? false : true - rotation: -rotationItem.rotation - readonly property real xOffset: Math.floor(parent.width/2.0 + ((rotationItem.width-mainWindow.width)/2)) - readonly property real yOffset: Math.floor(parent.height/2.0 + ((rotationItem.height-mainWindow.height)/2)) - x: -ma.mouseX + xOffset - y: -ma.mouseY + yOffset - width: mainWindow.width; height: mainWindow.height - } - } - - OpacityMask { - visible: Settings.zoomEnabled && mainWindow.spotOnCurrentWindow - cached: true - anchors.fill: centerRect - source: desktopItem - maskSource: spotShapeLoader.item - enabled: false - } - - Item { - anchors.fill: parent - MouseArea { - id: ma - - readonly property bool calculateMapping: Settings.multiScreenOverlayEnabled && !mainWindow.spotOnCurrentWindow - readonly property point globalPos: calculateMapping ? ProjecteurApp.currentCursorPos : Qt.point(0,0) - readonly property point mappedPos: calculateMapping ? mainWindow.contentItem.mapFromGlobal(globalPos.x, globalPos.y) : globalPos - readonly property int posX: spotOnCurrentWindow ? mouseX : mappedPos.x - readonly property int posY: spotOnCurrentWindow ? mouseY : mappedPos.y - - cursorShape: Settings.cursor - anchors.fill: parent - hoverEnabled: true - onClicked: { ProjecteurApp.spotlightWindowClicked() } - onExited: { ProjecteurApp.cursorExitedWindow() } - onEntered: { ProjecteurApp.cursorEntered(screenId) } - onPositionChanged: (mouse) => { - - if (Settings.multiScreenOverlayEnabled) { - ProjecteurApp.cursorPositionChanged( - mainWindow.contentItem.mapToGlobal(mouse.x, mouse.y)) - } - } - } - } - - Rectangle { - property int spotSize: (mainWindow.height / 100.0) * Settings.spotSize - id: centerRect - opacity: Settings.shadeOpacity - height: spotSize > 50 ? Math.min(spotSize, mainWindow.height) : 50 - width: height - x: ma.posX - width/2 - y: ma.posY - height/2 - color: Settings.shadeColor - visible: false - enabled: false - } - - Loader { - id: spotShapeLoader - visible: false; enabled: false - anchors.centerIn: centerRect - width: centerRect.width; height: width - sourceComponent: Qt.createComponent(Settings.spotShape) - } - - OpacityMask { - id: spot - visible: Settings.showSpotShade - opacity: centerRect.opacity - cached: true - invert: true - anchors.fill: centerRect - source: centerRect - maskSource: spotShapeLoader.item - enabled: false - } - - Loader { - id: borderShapeLoader - anchors.centerIn: centerRect - width: centerRect.width; height: width - visible: false; enabled: false - sourceComponent: spotShapeLoader.sourceComponent - onStatusChanged: { - if (status == Loader.Ready) { - borderShapeLoader.item.color = Qt.binding(function(){ return Settings.borderColor; }) - } - } - } - - Item { - id: borderShapeMask - anchors.centerIn: centerRect - width: centerRect.width; height: width - enabled: false; visible: false - Item { - id: borderShapeScaled - anchors.centerIn: parent - width: parent.width; height: width - scale: (100 - Settings.borderSize) * 1.0 / 100.0 - property Component component: borderShapeLoader.sourceComponent - property QtObject innerObject - onComponentChanged: { - if (innerObject) innerObject.destroy() - innerObject = component.createObject(borderShapeScaled, {visible: true}) - } - } - } - - OpacityMask { - id: spotBorder - visible: Settings.showBorder && Settings.borderSize > 0 - opacity: Settings.borderOpacity - cached: true - invert: true - anchors.fill: centerRect - source: borderShapeLoader.item - maskSource: borderShapeMask - enabled: false - } - - Rectangle { - id: dotCursor - antialiasing: true - anchors.centerIn: centerRect - width: Settings.dotSize; height: width - radius: width*0.5 - color: Settings.dotColor - visible: Settings.showCenterDot - opacity: Settings.dotOpacity - enabled: false - } - - Rectangle { - id: topRect - visible: spot.visible - color: centerRect.color - opacity: centerRect.opacity - anchors{ top: parent.top; bottom: centerRect.top; left: parent.left; right: parent.right } - enabled: false - } - - Rectangle { - id: bottomRect - visible: spot.visible - color: centerRect.color - opacity: centerRect.opacity - anchors{ top: centerRect.bottom; bottom: parent.bottom; left: parent.left; right: parent.right } - enabled: false - } - - Rectangle { - id: leftRect - visible: spot.visible - color: centerRect.color - opacity: centerRect.opacity - anchors{ top: topRect.bottom; bottom: bottomRect.top; left: parent.left; right: centerRect.left } - enabled: false - } - - Rectangle { - id: rightRect - visible: spot.visible - color: centerRect.color - opacity: centerRect.opacity - anchors{ top: topRect.bottom; bottom: bottomRect.top; left: centerRect.right; right: parent.right } - enabled: false - } - } -} // Window diff --git a/qml/main.qml b/qml/main.qml index bd970255..34efc61c 100644 --- a/qml/main.qml +++ b/qml/main.qml @@ -1,8 +1,8 @@ // This file is part of Projecteur - https://github.com/jahnf/projecteur - See LICENSE.md and README.md -import QtQuick 2.3 -import QtQuick.Window 2.2 - -import QtGraphicalEffects 1.0 +import QtQuick +import QtQuick.Effects +import QtQuick.Window +import org.kde.pipewire as KPipeWire import Projecteur.Utils 1.0 as Utils @@ -11,14 +11,20 @@ Window { property var screenId: -1 readonly property bool spotOnCurrentWindow: ProjecteurApp.currentSpotScreen === screenId property alias desktopPixmap: desktopImage.pixmap + property var desktopStream: null width: 300; height: 200 - flags: Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.SplashScreen + flags: Qt.FramelessWindowHint | Qt.WindowDoesNotAcceptFocus color: "transparent" readonly property double diagonal: Math.sqrt(Math.pow(Math.max(width, height),2)*2) + readonly property real deviceScale: screen ? screen.devicePixelRatio : 1.0 + + function snapToDevicePixel(value) { + return Math.round(value * deviceScale) / deviceScale + } Item { id: rotationItem @@ -28,7 +34,6 @@ Window { rotation: Settings.spotRotationAllowed ? Settings.spotRotation : 0 opacity: ProjecteurApp.overlayVisible ? 1.0 : 0.0 - Behavior on opacity { PropertyAnimation { easing.type: Easing.OutQuad } } Item { id: desktopItem @@ -37,24 +42,89 @@ Window { scale: Settings.zoomFactor width: centerRect.width / scale; height: centerRect.height / scale - Utils.Image { - id: desktopImage - smooth: rotation == 0 ? false : true + Item { + id: desktopSource rotation: -rotationItem.rotation readonly property real xOffset: Math.floor(parent.width/2.0 + ((rotationItem.width-mainWindow.width)/2)) readonly property real yOffset: Math.floor(parent.height/2.0 + ((rotationItem.height-mainWindow.height)/2)) - x: -ma.mouseX + xOffset - y: -ma.mouseY + yOffset + readonly property real rawX: -ma.mouseX + xOffset + readonly property real rawY: -ma.mouseY + yOffset + readonly property real sampleScaleX: desktopTexture.textureSize.width / parent.width + readonly property real sampleScaleY: desktopTexture.textureSize.height / parent.height + x: rotation == 0 ? Math.round(rawX * sampleScaleX) / sampleScaleX : rawX + y: rotation == 0 ? Math.round(rawY * sampleScaleY) / sampleScaleY : rawY width: mainWindow.width; height: mainWindow.height + + Utils.Image { + id: desktopImage + anchors.fill: parent + smooth: desktopSource.rotation != 0 || mainWindow.deviceScale != 1.0 + visible: !desktopStreamItem.ready + } + + ShaderEffectSource { + anchors.fill: parent + sourceItem: desktopStreamItem + sourceRect: Qt.rect(0, 0, + desktopStreamItem.width, + desktopStreamItem.height) + live: true + smooth: true + visible: desktopStreamItem.ready + } } } - OpacityMask { + KPipeWire.PipeWireSourceItem { + id: desktopStreamItem + visible: mainWindow.visible + enabled: false + width: mainWindow.width + height: mainWindow.height + nodeId: mainWindow.desktopStream + ? mainWindow.desktopStream.nodeId : 0 + allowDmaBuf: true + } + + ShaderEffectSource { + id: desktopTexture + readonly property bool useDirectStream: desktopStreamItem.ready + anchors.fill: centerRect + visible: false + sourceItem: useDirectStream ? desktopStreamItem : desktopItem + hideSource: useDirectStream + sourceRect: useDirectStream + ? Qt.rect( + mainWindow.snapToDevicePixel( + centerRect.x + centerRect.width / 2 - desktopItem.width / 2), + mainWindow.snapToDevicePixel( + centerRect.y + centerRect.height / 2 - desktopItem.height / 2), + desktopItem.width, + desktopItem.height) + : Qt.rect(0, 0, desktopItem.width, desktopItem.height) + smooth: Settings.zoomMode !== "pixel" + textureSize: Qt.size( + Math.max(1, Math.round(desktopItem.width * mainWindow.deviceScale)), + Math.max(1, Math.round(desktopItem.height * mainWindow.deviceScale))) + } + + ShaderEffect { + id: textZoom + anchors.fill: centerRect + visible: false + property variant source: desktopTexture + property size outputSize: Qt.size( + Math.max(1, Math.round(width * mainWindow.deviceScale)), + Math.max(1, Math.round(height * mainWindow.deviceScale))) + fragmentShader: "qrc:/shaders/textzoom.frag.qsb" + } + + MultiEffect { visible: Settings.zoomEnabled && mainWindow.spotOnCurrentWindow - cached: true anchors.fill: centerRect - source: desktopItem - maskSource: spotShapeLoader.item + source: Settings.zoomMode === "text" ? textZoom : desktopTexture + maskEnabled: true + maskSource: spotShapeLoader enabled: false } @@ -75,7 +145,7 @@ Window { onClicked: { ProjecteurApp.spotlightWindowClicked() } onExited: { ProjecteurApp.cursorExitedWindow() } onEntered: { ProjecteurApp.cursorEntered(screenId) } - onPositionChanged: { + onPositionChanged: (mouse) => { if (Settings.multiScreenOverlayEnabled) { ProjecteurApp.cursorPositionChanged( @@ -91,8 +161,8 @@ Window { opacity: Settings.shadeOpacity height: spotSize > 50 ? Math.min(spotSize, mainWindow.height) : 50 width: height - x: ma.posX - width/2 - y: ma.posY - height/2 + x: mainWindow.snapToDevicePixel(ma.posX - width/2) + y: mainWindow.snapToDevicePixel(ma.posY - height/2) color: Settings.shadeColor visible: false enabled: false @@ -103,18 +173,20 @@ Window { visible: false; enabled: false anchors.centerIn: centerRect width: centerRect.width; height: width + layer.enabled: true sourceComponent: Qt.createComponent(Settings.spotShape) + onLoaded: item.visible = true } - OpacityMask { + MultiEffect { id: spot visible: Settings.showSpotShade opacity: centerRect.opacity - cached: true - invert: true anchors.fill: centerRect source: centerRect - maskSource: spotShapeLoader.item + maskEnabled: true + maskInverted: true + maskSource: spotShapeLoader enabled: false } @@ -123,11 +195,11 @@ Window { anchors.centerIn: centerRect width: centerRect.width; height: width visible: false; enabled: false + layer.enabled: true sourceComponent: spotShapeLoader.sourceComponent - onStatusChanged: { - if (status == Loader.Ready) { - borderShapeLoader.item.color = Qt.binding(function(){ return Settings.borderColor; }) - } + onLoaded: { + item.visible = true + item.color = Qt.binding(function(){ return Settings.borderColor; }) } } @@ -136,6 +208,7 @@ Window { anchors.centerIn: centerRect width: centerRect.width; height: width enabled: false; visible: false + layer.enabled: true Item { id: borderShapeScaled anchors.centerIn: parent @@ -150,14 +223,14 @@ Window { } } - OpacityMask { + MultiEffect { id: spotBorder visible: Settings.showBorder && Settings.borderSize > 0 opacity: Settings.borderOpacity - cached: true - invert: true anchors.fill: centerRect - source: borderShapeLoader.item + source: borderShapeLoader + maskEnabled: true + maskInverted: true maskSource: borderShapeMask enabled: false } diff --git a/qml/qml-qt6.qrc b/qml/qml-qt6.qrc deleted file mode 100644 index f90b4c54..00000000 --- a/qml/qml-qt6.qrc +++ /dev/null @@ -1,9 +0,0 @@ - - - main-qt6.qml - spotshapes/Circle.qml - spotshapes/Square.qml - spotshapes/Star.qml - spotshapes/Ngon.qml - - diff --git a/qml/shaders/textzoom.frag b/qml/shaders/textzoom.frag new file mode 100644 index 00000000..edbe9258 --- /dev/null +++ b/qml/shaders/textzoom.frag @@ -0,0 +1,71 @@ +#version 440 + +layout(location = 0) in vec2 qt_TexCoord0; +layout(location = 0) out vec4 fragColor; + +layout(std140, binding = 0) uniform buf { + mat4 qt_Matrix; + float qt_Opacity; + vec2 outputSize; +}; + +layout(binding = 1) uniform sampler2D source; + +vec3 srgbToLinear(vec3 color) +{ + vec3 low = color / 12.92; + vec3 high = pow((color + 0.055) / 1.055, vec3(2.4)); + return mix(low, high, step(vec3(0.04045), color)); +} + +vec3 linearToSrgb(vec3 color) +{ + vec3 low = color * 12.92; + vec3 high = 1.055 * pow(max(color, vec3(0.0)), vec3(1.0 / 2.4)) - 0.055; + return mix(low, high, step(vec3(0.0031308), color)); +} + +vec4 unpremultipliedSample(vec2 position) +{ + vec4 sampleColor = texture(source, position); + if (sampleColor.a > 0.0) { + sampleColor.rgb /= sampleColor.a; + } + return sampleColor; +} + +void main() +{ + vec2 pixelStep = vec2(1.0) / outputSize; + + vec4 centerSample = unpremultipliedSample(qt_TexCoord0); + vec4 northSample = unpremultipliedSample(qt_TexCoord0 - vec2(0.0, pixelStep.y)); + vec4 southSample = unpremultipliedSample(qt_TexCoord0 + vec2(0.0, pixelStep.y)); + vec4 westSample = unpremultipliedSample(qt_TexCoord0 - vec2(pixelStep.x, 0.0)); + vec4 eastSample = unpremultipliedSample(qt_TexCoord0 + vec2(pixelStep.x, 0.0)); + + vec3 center = srgbToLinear(centerSample.rgb); + vec3 north = srgbToLinear(northSample.rgb); + vec3 south = srgbToLinear(southSample.rgb); + vec3 west = srgbToLinear(westSample.rgb); + vec3 east = srgbToLinear(eastSample.rgb); + + vec3 localMinimum = min(center, min(min(north, south), min(west, east))); + vec3 localMaximum = max(center, max(max(north, south), max(west, east))); + vec3 localBlur = (4.0 * center + north + south + west + east) / 8.0; + + const vec3 luminanceWeights = vec3(0.2126, 0.7152, 0.0722); + float minimumLuminance = dot(localMinimum, luminanceWeights); + float maximumLuminance = dot(localMaximum, luminanceWeights); + float localContrast = maximumLuminance - minimumLuminance; + + // Keep low-contrast and photographic regions close to the smooth source. + // Text and UI edges get a bounded high-frequency boost with no overshoot. + float edgeConfidence = smoothstep(0.015, 0.18, localContrast); + vec3 enhanced = center + (center - localBlur) * (1.25 * edgeConfidence); + enhanced = clamp(enhanced, localMinimum, localMaximum); + + vec3 outputColor = linearToSrgb(clamp(enhanced, 0.0, 1.0)); + float outputAlpha = centerSample.a; + fragColor = vec4(outputColor * outputAlpha, outputAlpha) * qt_Opacity; +} diff --git a/src/aboutdlg.cc b/src/aboutdlg.cc deleted file mode 100644 index 167e084f..00000000 --- a/src/aboutdlg.cc +++ /dev/null @@ -1,265 +0,0 @@ -// This file is part of Projecteur - https://github.com/jahnf/projecteur -// - See LICENSE.md and README.md - -#include "aboutdlg.h" - -#include "projecteur-GitVersion.h" - -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -namespace { - // ----------------------------------------------------------------------------------------------- - /// Contributor (name, github_name, email, url) - struct Contributor - { - explicit Contributor(const QString& name = {}, const QString& github_name = {}, - const QString& email = {}, const QString& url = {}) - : name(name), github_name(github_name), email(email), url(url) {} - - QString toHtml() const - { - auto html = QString("%1").arg(name.isEmpty() - ? QString("%1").arg(github_name) - : name); - - if (email.size()) { - html += QString(" <%1>").arg(email); - } - - if (url.size()) { - html += QString(" %1").arg(url); - } - else if (!name.isEmpty()) { - html += QString(" - github: %1").arg(github_name); - } - return html; - } - - QString name; - QString github_name; - QString email; - QString url; - }; - - // ----------------------------------------------------------------------------------------------- - QString getContributorsHtml() - { - static std::vector contributors = - { - Contributor("Ricardo Jesus", "rj-jesus"), - Contributor("Mayank Suman", "mayanksuman"), - Contributor("Tiziano MĂĽller", "dev-zero"), - Contributor("Torsten Maehne", "maehne"), - Contributor("TBK", "TBK"), - Contributor("Louie Lu", "mlouielu"), - Contributor("fmuelle4711", "fmuelle4711"), - Contributor("Deniz Bahadir", "Bagira80"), - Contributor("Tomáš Chvátal", "scarabeusiv"), - Contributor("Brandon Johnson", "dbrandonjohnson"), - Contributor("Stuart Prescott", "llimeht"), - Contributor("Crista Renouard", "Lumnicence"), - Contributor("freddii", "freddii"), - Contributor("Matthias BlĂĽmel", "Blaimi"), - Contributor("Grzegorz Szymaszek", "gszy"), - Contributor("TheAssassin", "TheAssassin"), - }; - - static std::mt19937 g(std::random_device{}()); - std::shuffle(contributors.begin(), contributors.end(), g); - - QStringList contributorsHtml; - for (const auto& contributor : contributors) { - contributorsHtml.append(contributor.toHtml()); - } - return contributorsHtml.join("
"); - } -} // end anonymous namespace - -// ------------------------------------------------------------------------------------------------- -AboutDialog::AboutDialog(QWidget* parent) - : QDialog(parent) - , m_tabWidget(new QTabWidget(this)) -{ - setWindowTitle(tr("About %1", "%1=application name").arg(QCoreApplication::applicationName())); - setWindowIcon(QIcon(":/icons/projecteur-tray.svg")); - - const auto hbox = new QHBoxLayout(); - const auto iconLabel = new QLabel(this); - iconLabel->setPixmap(QIcon(":/icons/projecteur-tray.svg").pixmap(QSize(128,128))); - hbox->addWidget(iconLabel); - - hbox->addWidget(m_tabWidget, 1); - - m_tabWidget->addTab(createVersionInfoWidget(), tr("Version")); - m_tabWidget->addTab(createContributorInfoWidget(), tr("Contributors")); - m_tabWidget->addTab(createThirdPartyLicensesWidget(), tr("Licenses")); - - const auto bbox = new QDialogButtonBox(QDialogButtonBox::Ok, this); - connect(bbox, &QDialogButtonBox::clicked, this, &QDialog::accept); - - const auto mainVbox = new QVBoxLayout(this); - mainVbox->addLayout(hbox); - mainVbox->addSpacing(10); - mainVbox->addWidget(bbox); -} - -// ------------------------------------------------------------------------------------------------- -void AboutDialog::showEvent(QShowEvent* e) -{ - QDialog::showEvent(e); - m_tabWidget->setCurrentIndex(0); -} - -// ------------------------------------------------------------------------------------------------- -QWidget* AboutDialog::createVersionInfoWidget() -{ - const auto versionInfoWidget = new QWidget(this); - const auto vbox = new QVBoxLayout(versionInfoWidget); - const auto versionLabel = new QLabel(QString("%1
%2") - .arg(QCoreApplication::applicationName(), - tr("Version %1", "%1=application version number") - .arg(projecteur::version_string())), this); - vbox->addWidget(versionLabel); - const auto vInfo = QString("git-branch: %1
git-hash: %2
build-type: %3") - .arg(projecteur::version_branch(), - projecteur::version_shorthash(), - projecteur::version_buildtype()); - versionLabel->setToolTip(vInfo); - - if (QString(projecteur::version_flag()).size() || - (QString(projecteur::version_branch()) != "master" - && QString(projecteur::version_branch()) != "not-within-git-repo")) - { - vbox->addSpacing(4); - vbox->addWidget(new QLabel(vInfo, this)); - } - - vbox->addSpacing(4); - const auto weblinkLabel = new QLabel(QString("" - "https://github.com/jahnf/Projecteur"), this); - weblinkLabel->setOpenExternalLinks(true); - vbox->addWidget(weblinkLabel); - vbox->addSpacing(8); - - auto qtVerText = tr("Qt Version: %1", "%1=qt version number").arg(QT_VERSION_STR); - if (QString(QT_VERSION_STR) != qVersion()) { - qtVerText += QString(" (runtime: %1)").arg(qVersion()); - } - vbox->addWidget(new QLabel(qtVerText, this)); - vbox->addSpacing(15); - vbox->addWidget(new QLabel("Copyright 2018-2021 Jahn Fuchs", this)); - auto licenseText = new QLabel(tr("This project is distributed under the
" - "" - "MIT License"), this); - licenseText->setWordWrap(true); - licenseText->setTextFormat(Qt::TextFormat::RichText); - licenseText->setOpenExternalLinks(true); - vbox->addWidget(licenseText); - - vbox->addStretch(1); - return versionInfoWidget; -} - -// ------------------------------------------------------------------------------------------------- -QWidget* AboutDialog::createContributorInfoWidget() -{ - const auto contributorWidget = new QWidget(this); - const auto vbox = new QVBoxLayout(contributorWidget); - - const auto label = new QLabel(tr("Contributors, in random order:"), contributorWidget); - vbox->addWidget(label); - - const auto textBrowser = new QTextBrowser(contributorWidget); - textBrowser->setWordWrapMode(QTextOption::NoWrap); - textBrowser->setOpenLinks(true); - textBrowser->setOpenExternalLinks(true); - textBrowser->setFont([textBrowser]() - { - auto font = textBrowser->font(); - font.setPointSizeF(font.pointSizeF() - 2.0); - return font; - }()); - - // randomize contributors list on every contributors tab selection - connect(m_tabWidget, &QTabWidget::currentChanged, this, - [contributorWidget, textBrowser, this](int){ - if (contributorWidget == m_tabWidget->currentWidget()) { - textBrowser->setHtml(getContributorsHtml()); - } - }); - - vbox->addWidget(textBrowser); - return contributorWidget; -} - -// ------------------------------------------------------------------------------------------------- -QWidget* AboutDialog::createThirdPartyLicensesWidget() -{ - const auto tpLicenceWidget = new QWidget(this); - const auto layout = new QVBoxLayout(tpLicenceWidget); - - struct ThirdPartyProject { - const QString projectName; - const QString projectUrl; - const QString copyrightNotice; - const QString licenseName; - const QString licenseUrl; - }; - - static const std::vector thirdPartyProjects = { - ThirdPartyProject{ "Qt Toolkit", "https://www.qt.io", "Copyright (C) The Qt Company Ltd.", "GPL/LGPLv3", "" }, - }; - - const auto textBrowser = new QTextBrowser(tpLicenceWidget); - layout->addWidget(textBrowser); - - textBrowser->setOpenLinks(true); - textBrowser->setOpenExternalLinks(true); - textBrowser->setWordWrapMode(QTextOption::NoWrap); - textBrowser->setFont([textBrowser]() - { - auto font = textBrowser->font(); - font.setPointSizeF(font.pointSizeF() - 2.5); - return font; - }()); - - QString html = ""; - - html += "

    "; - for (const auto& tpl : thirdPartyProjects) - { - html += "
  • "; - if (tpl.projectUrl.size()) { - html += QString("%2").arg(tpl.projectUrl, tpl.projectName); - } else { - html += QString("%1").arg(tpl.projectName); - } - - if (tpl.copyrightNotice.size()) { - html += "
    " + tpl.copyrightNotice + ""; - } - - if (tpl.licenseUrl.size()) { - html += QString("
    %2").arg(tpl.licenseUrl, tpl.licenseName); - } else { - html += QString("
    License: %1").arg(tpl.licenseName); - } - - html += "
  • "; - } - html += "
"; - - textBrowser->setHtml(html); - return tpLicenceWidget; -} - diff --git a/src/aboutdlg.h b/src/aboutdlg.h deleted file mode 100644 index 9b295364..00000000 --- a/src/aboutdlg.h +++ /dev/null @@ -1,25 +0,0 @@ -// This file is part of Projecteur - https://github.com/jahnf/projecteur -// - See LICENSE.md and README.md -#pragma once - -#include - -class QTabWidget; - -class AboutDialog : public QDialog -{ - Q_OBJECT - -public: - explicit AboutDialog(QWidget* parent = nullptr); - -protected: - void showEvent(QShowEvent*) override; - -private: - QTabWidget* m_tabWidget = nullptr; - - QWidget* createVersionInfoWidget(); - QWidget* createContributorInfoWidget(); - QWidget* createThirdPartyLicensesWidget(); -}; diff --git a/src/actiondelegate.cc b/src/actiondelegate.cc index dea30b69..b445fadf 100644 --- a/src/actiondelegate.cc +++ b/src/actiondelegate.cc @@ -8,6 +8,8 @@ #include "nativekeyseqedit.h" #include "projecteur-icons-def.h" +#include + #include #include #include @@ -28,13 +30,8 @@ namespace { constexpr int verticalMargin = 3; constexpr int horizontalMargin = 3; const int h = opt.fontMetrics.height() + 2 * verticalMargin; - #if (QT_VERSION >= QT_VERSION_CHECK(5, 11, 0)) - const int w = std::max(opt.fontMetrics.horizontalAdvance(ActionDelegate::tr("None")) + 2 * horizontalMargin, + const int w = std::max(opt.fontMetrics.horizontalAdvance(i18n("None")) + 2 * horizontalMargin, opt.fontMetrics.horizontalAdvance(action->keySequence.toString())); - #else - const int w = std::max(opt.fontMetrics.width(ActionDelegate::tr("None")) + 2 * horizontalMargin, - opt.fontMetrics.width(action->keySequence.toString())); - #endif return { w, h }; } } // end namespace keysequence @@ -45,7 +42,7 @@ namespace { { const auto& fm = option.fontMetrics; const int xPos = (option.rect.height()-fm.height()) / 2; - NativeKeySeqEdit::drawText(xPos, *p, option, ActionDelegate::tr("Cycle Presets")); + NativeKeySeqEdit::drawText(xPos, *p, option, i18n("Cycle Presets")); } // --------------------------------------------------------------------------------------------- @@ -60,7 +57,7 @@ namespace { { const auto& fm = option.fontMetrics; const int xPos = (option.rect.height()-fm.height()) / 2; - NativeKeySeqEdit::drawText(xPos, *p, option, ActionDelegate::tr("Toggle Spotlight")); + NativeKeySeqEdit::drawText(xPos, *p, option, i18n("Toggle Spotlight")); } // --------------------------------------------------------------------------------------------- @@ -75,7 +72,7 @@ namespace { { const auto& fm = option.fontMetrics; const int xPos = (option.rect.height()-fm.height()) / 2; - NativeKeySeqEdit::drawText(xPos, *p, option, ActionDelegate::tr("Scroll Horizontal")); + NativeKeySeqEdit::drawText(xPos, *p, option, i18n("Scroll Horizontal")); } // --------------------------------------------------------------------------------------------- @@ -90,7 +87,7 @@ namespace { { const auto& fm = option.fontMetrics; const int xPos = (option.rect.height()-fm.height()) / 2; - NativeKeySeqEdit::drawText(xPos, *p, option, ActionDelegate::tr("Scroll Vertical")); + NativeKeySeqEdit::drawText(xPos, *p, option, i18n("Scroll Vertical")); } // --------------------------------------------------------------------------------------------- @@ -105,7 +102,7 @@ namespace { { const auto& fm = option.fontMetrics; const int xPos = (option.rect.height()-fm.height()) / 2; - NativeKeySeqEdit::drawText(xPos, *p, option, ActionDelegate::tr("Volume Control")); + NativeKeySeqEdit::drawText(xPos, *p, option, i18n("Volume Control")); } // --------------------------------------------------------------------------------------------- @@ -315,12 +312,12 @@ void ActionTypeDelegate::paint(QPainter* painter, const QStyleOptionViewItem& op const auto symbol = [&item]() -> QChar { switch(item.action->type()) { - case Action::Type::KeySequence: return QChar(Font::Icon::keyboard_4); - case Action::Type::CyclePresets: return QChar(Font::Icon::connection_8); - case Action::Type::ToggleSpotlight: return QChar(Font::Icon::power_on_off_11); - case Action::Type::ScrollHorizontal: return QChar(Font::Icon::cursor_21_rotated); - case Action::Type::ScrollVertical: return QChar(Font::Icon::cursor_21); - case Action::Type::VolumeControl: return QChar(Font::Icon::audio_6); + case Action::Type::KeySequence: return QChar(static_cast(Font::Icon::keyboard_4)); + case Action::Type::CyclePresets: return QChar(static_cast(Font::Icon::connection_8)); + case Action::Type::ToggleSpotlight: return QChar(static_cast(Font::Icon::power_on_off_11)); + case Action::Type::ScrollHorizontal: return QChar(static_cast(Font::Icon::cursor_21_rotated)); + case Action::Type::ScrollVertical: return QChar(static_cast(Font::Icon::cursor_21)); + case Action::Type::VolumeControl: return QChar(static_cast(Font::Icon::audio_6)); } return QChar(0); }(); @@ -352,12 +349,12 @@ void ActionTypeDelegate::actionContextMenu(QWidget* parent, InputMapConfigModel* }; static std::vector items { - {Action::Type::KeySequence, QChar(Font::Icon::keyboard_4), tr("Key Sequence"), false}, - {Action::Type::CyclePresets, QChar(Font::Icon::connection_8), tr("Cycle Presets"), false}, - {Action::Type::ToggleSpotlight, QChar(Font::Icon::power_on_off_11), tr("Toggle Spotlight"), false}, - {Action::Type::ScrollHorizontal, QChar(Font::Icon::cursor_21_rotated), tr("Scroll Horizontal"), true}, - {Action::Type::ScrollVertical, QChar(Font::Icon::cursor_21), tr("Scroll Vertical"), true}, - {Action::Type::VolumeControl, QChar(Font::Icon::audio_6), tr("Volume Control"), true}, + {Action::Type::KeySequence, QChar(static_cast(Font::Icon::keyboard_4)), i18n("Key Sequence"), false}, + {Action::Type::CyclePresets, QChar(static_cast(Font::Icon::connection_8)), i18n("Cycle Presets"), false}, + {Action::Type::ToggleSpotlight, QChar(static_cast(Font::Icon::power_on_off_11)), i18n("Toggle Spotlight"), false}, + {Action::Type::ScrollHorizontal, QChar(static_cast(Font::Icon::cursor_21_rotated)), i18n("Scroll Horizontal"), true}, + {Action::Type::ScrollVertical, QChar(static_cast(Font::Icon::cursor_21)), i18n("Scroll Vertical"), true}, + {Action::Type::VolumeControl, QChar(static_cast(Font::Icon::audio_6)), i18n("Volume Control"), true}, }; static bool initIcons = []() diff --git a/src/asynchronous.h b/src/asynchronous.h index aded391e..155f06eb 100644 --- a/src/asynchronous.h +++ b/src/asynchronous.h @@ -5,11 +5,6 @@ #include #include -#if (QT_VERSION < QT_VERSION_CHECK(5, 10, 0)) -#include -#include -#endif - #include #include #include @@ -35,49 +30,25 @@ constexpr decltype(auto) apply(F&& f, Tuple&& t){ std::make_index_sequence>::value>{}); } -// Capture args and add them as additional arguments +// Capture arguments for a zero-argument queued invocation. template auto capture_call(Lambda&& lambda, Args&& ... args){ return [ lambda = std::forward(lambda), capture_args = std::make_tuple(std::forward(args) ...) - ](auto&& ... original_args)mutable{ + ]() mutable { return async::apply([&lambda](auto&& ... args){ lambda(std::forward(args) ...); }, - std::tuple_cat( - std::forward_as_tuple(original_args ...), - async::apply([](auto&& ... args){ - return std::forward_as_tuple( - std::move(args) ...); - }, std::move(capture_args)) - )); + std::move(capture_args)); }; } -#if (QT_VERSION >= QT_VERSION_CHECK(5, 10, 0)) // Invoke a (lambda) function for context QObject with queued connection. template void invoke(QObject* context, F&& function) { QMetaObject::invokeMethod(context, std::forward(function), Qt::QueuedConnection); } -#else -// ... older Qt versions < 5.10 -namespace detail { -template -struct FEvent : public QEvent { - using Fun = typename std::decay::type; - Fun fun; - FEvent(Fun && fun) : QEvent(QEvent::None), fun(std::move(fun)) {} - FEvent(const Fun & fun) : QEvent(QEvent::None), fun(fun) {} - ~FEvent() { fun(); } -}; } - -template -void invoke(QObject* context, F&& function) { - QCoreApplication::postEvent(context, new detail::FEvent(std::forward(function))); -} -#endif // --- Helpers to deduce std::function type from a lambda. template @@ -106,16 +77,11 @@ auto makeSafeCallback_impl(QObject* context, F&& f, std::function, b return; } - #if (QT_VERSION >= QT_VERSION_CHECK(5, 10, 0)) QMetaObject::invokeMethod(ctxPtr, capture_call(std::forward(f), std::forward(args)...), autoConnection ? Qt::AutoConnection : Qt::QueuedConnection); // Note: if forceQueued is false and current thread is the same as // the context thread -> execute directly - #else - // For Qt < 5.10 the call is always queued via the event queue - async::invoke(ctxPtr, capture_call(std::forward(f), std::forward(args)...)); - #endif }; } diff --git a/src/colorselector.cc b/src/colorselector.cc deleted file mode 100644 index 5a0187d2..00000000 --- a/src/colorselector.cc +++ /dev/null @@ -1,105 +0,0 @@ -// This file is part of Projecteur - https://github.com/jahnf/projecteur -// - See LICENSE.md and README.md - -#include "colorselector.h" - -#include -#include -#include -#include - -#include - -namespace { - std::unique_ptr colorButtonStyle = std::make_unique(); - - QColor mixColors(const QColor& a, const QColor& b, double ratio = 0.5) { - return QColor( - a.red() *(1.0-ratio) + b.red() *ratio, - a.green()*(1.0-ratio) + b.green()*ratio, - a.blue() *(1.0-ratio) + b.blue() *ratio, - 255 - ); - } -} // end anonymous namespace - -ColorSelectorButtonStyle::ColorSelectorButtonStyle() -{ - setObjectName("ColorSelectorButtontyle"); -} - -void ColorSelectorButtonStyle::drawPrimitive(PrimitiveElement element, const QStyleOption *option, - QPainter *p, const QWidget *widget) const -{ - if (element != PE_PanelButtonCommand) - QProxyStyle::drawPrimitive(element, option, p, widget); - - p->save(); - - p->setRenderHint(QPainter::Antialiasing); - p->translate(0.5, -0.5); - QPainterPath path; - const auto rect = option->rect.adjusted(1,1,-1,0); - path.addRoundedRect(rect, 4, 4); - - - const auto borderColor = [option]() - { // Set border color based on window color - const auto w = option->palette.color(QPalette::Window); - const auto c = (w.redF() * 0.299 + w.greenF() * 0.587 + w.blueF() * 0.114 ) > 0.6 ? Qt::darkGray - : Qt::lightGray; - if (option->state & State_Enabled) return QColor(c); - return mixColors(c, option->palette.color(QPalette::Disabled, QPalette::Button)); - }(); - - const auto buttonBrush = [option]() { - if (option->state & State_Enabled) return option->palette.button(); - return QBrush(mixColors(option->palette.color(QPalette::Normal, QPalette::Button), - option->palette.color(QPalette::Disabled, QPalette::Button))); - }(); - - p->setPen(QPen(borderColor, 1)); - p->fillPath(path, buttonBrush); - p->drawPath(path); - - p->restore(); -} - -ColorSelector::ColorSelector(QWidget* parent) - : ColorSelector(tr("Select Color"), Qt::black, parent) -{ -} - -ColorSelector::ColorSelector(const QString& selectionDialogTitle, const QColor& color, QWidget* parent) - : QPushButton(parent) - , m_color(color) -{ - setStyle(colorButtonStyle.get()); - - setMinimumWidth(30); - updateButton(); - connect(this, &QPushButton::clicked, [this, selectionDialogTitle](){ - const QColor c = QColorDialog::getColor(m_color, this, selectionDialogTitle); - if (c.isValid()) - setColor(c); - }); -} - -void ColorSelector::setColor(const QColor& color) -{ - if (m_color == color) - return; - - m_color = color; - updateButton(); - emit colorChanged(color); -} - -void ColorSelector::updateButton() -{ - QPalette p(palette()); - p.setColor(QPalette::Button, m_color); - p.setColor(QPalette::ButtonText, m_color); - setPalette(p); - setToolTip(m_color.name()); -} diff --git a/src/colorselector.h b/src/colorselector.h deleted file mode 100644 index be922c87..00000000 --- a/src/colorselector.h +++ /dev/null @@ -1,39 +0,0 @@ -// This file is part of Projecteur - https://github.com/jahnf/projecteur -// - See LICENSE.md and README.md -#pragma once - -#include -#include - -class ColorSelectorButtonStyle : public QProxyStyle -{ - Q_OBJECT - -public: - ColorSelectorButtonStyle(); - void drawPrimitive(PrimitiveElement element, const QStyleOption *option, - QPainter *painter, const QWidget *widget) const override; -}; - - -class ColorSelector : public QPushButton -{ - Q_OBJECT - Q_PROPERTY(QColor color READ color WRITE setColor NOTIFY colorChanged) - -public: - explicit ColorSelector(QWidget* parent = nullptr); - explicit ColorSelector(const QString& selectionDialogTitle, const QColor& color, QWidget* parent = nullptr); - - void setColor(const QColor& color); - QColor color() const { return m_color; } - -signals: - void colorChanged(QColor); - -private: - void updateButton(); - -private: - QColor m_color; -}; diff --git a/src/device-command-helper.cc b/src/device-command-helper.cc index 85ad73df..a2e1cc3a 100644 --- a/src/device-command-helper.cc +++ b/src/device-command-helper.cc @@ -24,29 +24,43 @@ bool DeviceCommandHelper::sendVibrateCommand(uint8_t intensity, uint8_t length) return false; } - for ( auto const& dev : m_spotlight->connectedDevices()) { - if (auto connection = m_spotlight->deviceConnection(dev.id)) { - if (!connection->hasHidppSupport()) { - continue; - } - - for (auto const& subInfo : connection->subDevices()) { - auto const& subConn = subInfo.second; - if (!subConn || !subConn->hasFlags(DeviceFlag::Vibrate)) { - continue; - } - - if (auto hidppConn = std::dynamic_pointer_cast(subConn)) - { - hidppConn->sendVibrateCommand(intensity, length, - [](HidppConnectionInterface::MsgResult, HIDPP::Message&&) { - // logDebug(hid) << tr("Vibrate command returned: %1 (%2)") - // .arg(toString(result)).arg(msg.hex()); - }); - } - } + bool commandSent = false; + for (const auto& device : m_spotlight->connectedDevices()) { + commandSent = sendVibrateCommand(device.id, intensity, length) || commandSent; + } + return commandSent; +} + +// ------------------------------------------------------------------------------------------------- +bool DeviceCommandHelper::sendVibrateCommand(const DeviceId& deviceId, uint8_t intensity, + uint8_t length) +{ + if (m_spotlight.isNull()) { + return false; + } + + const auto connection = m_spotlight->deviceConnection(deviceId); + if (!connection || !connection->hasHidppSupport()) { + return false; + } + + bool commandSent = false; + for (const auto& subInfo : connection->subDevices()) + { + const auto& subConnection = subInfo.second; + if (!subConnection || !subConnection->hasFlags(DeviceFlag::Vibrate)) { + continue; + } + + if (const auto hidppConnection = + std::dynamic_pointer_cast(subConnection)) + { + hidppConnection->sendVibrateCommand(intensity, length, + [](HidppConnectionInterface::MsgResult, HIDPP::Message&&) { + }); + commandSent = true; } } - return true; + return commandSent; } diff --git a/src/device-command-helper.h b/src/device-command-helper.h index 00fb34ed..01ae14f7 100644 --- a/src/device-command-helper.h +++ b/src/device-command-helper.h @@ -6,6 +6,7 @@ #include class Spotlight; +struct DeviceId; /// Class that offers easy access to device commands with a given Spotlight /// instance. @@ -18,6 +19,7 @@ class DeviceCommandHelper : public QObject virtual ~DeviceCommandHelper(); bool sendVibrateCommand(uint8_t intensity, uint8_t length); + bool sendVibrateCommand(const DeviceId& deviceId, uint8_t intensity, uint8_t length); private: QPointer m_spotlight; diff --git a/src/device-defs.h b/src/device-defs.h index 6539586d..22adb954 100644 --- a/src/device-defs.h +++ b/src/device-defs.h @@ -20,6 +20,11 @@ const char* toString(BusType bt, bool withClass = true); const char* toString(ConnectionType ct, bool withClass = true); const char* toString(ConnectionMode cm, bool withClass = true); +inline QString formatHexId(uint16_t id) +{ + return QStringLiteral("%1").arg(id, 4, 16, QLatin1Char('0')); +} + // ------------------------------------------------------------------------------------------------- struct DeviceId { diff --git a/src/device-hidpp.cc b/src/device-hidpp.cc index a6e93682..faee3f27 100644 --- a/src/device-hidpp.cc +++ b/src/device-hidpp.cc @@ -5,14 +5,26 @@ #include "deviceinput.h" #include "enum-helper.h" -#include "logging.h" +#include "projecteur_hid_debug.h" #include #include #include -DECLARE_LOGGING_CATEGORY(hid) +namespace { + // The HAPTIC feature uses a percentage, while Projecteur's existing vibration + // interface uses the original Spotlight's byte-sized intensity. + constexpr uint8_t hapticLevelFromIntensity(uint8_t intensity) + { + return static_cast( + (static_cast(intensity) * 100U + 127U) / 255U); + } + + static_assert(hapticLevelFromIntensity(0) == 0); + static_assert(hapticLevelFromIntensity(128) == 50); + static_assert(hapticLevelFromIntensity(255) == 100); +} // ------------------------------------------------------------------------------------------------- SubHidppConnection::SubHidppConnection(SubHidrawConnection::Token token, @@ -21,6 +33,10 @@ SubHidppConnection::SubHidppConnection(SubHidrawConnection::Token token, , m_featureSet(this) , m_requestCleanupTimer(new QTimer(this)) { + // A Bolt receiver can have the presenter paired in any of its six slots. + // Other supported connections have historically used slot 1. + m_deviceIndexKnown = id.busType != BusType::Usb || id.productId != 0xc548; + constexpr int cleanUpTimerInterval = 500; m_requestCleanupTimer->setInterval(cleanUpTimerInterval); m_requestCleanupTimer->setSingleShot(false); @@ -81,8 +97,8 @@ ssize_t SubHidppConnection::sendData(HIDPP::Message msg) if (busType() == BusType::Bluetooth) { if (msg.deviceIndex() == HIDPP::DeviceIndex::DefaultDevice) { - logWarn(hid) << tr("Invalid message device index in data '%1' for device connected " - "via bluetooth.").arg(msg.hex()); + qCWarning(PROJECTEUR_HID_LOG).noquote() << QStringLiteral("Invalid message device index in data '%1' for device connected " + "via bluetooth.").arg(msg.hex()); return errorResult; } @@ -138,19 +154,16 @@ void SubHidppConnection::sendRequest(HIDPP::Message msg, RequestResultCallback r } // Device index sanity check - static const std::array validDeviceIndexes { - HIDPP::DeviceIndex::CordedDevice, - HIDPP::DeviceIndex::DefaultDevice, - HIDPP::DeviceIndex::WirelessDevice1, - }; - - const auto deviceIndexIt - = std::find(validDeviceIndexes.cbegin(), validDeviceIndexes.cend(), msg.deviceIndex()); - - if (deviceIndexIt == validDeviceIndexes.cend()) + const auto index = msg.deviceIndex(); + const bool validDeviceIndex = + index == HIDPP::DeviceIndex::CordedDevice + || index == HIDPP::DeviceIndex::DefaultDevice + || (index >= HIDPP::DeviceIndex::WirelessDevice1 + && index <= HIDPP::DeviceIndex::WirelessDevice6); + + if (!validDeviceIndex) { - logWarn(hid) << tr("Invalid device index (%1) in message for '%2'") - .arg(msg.deviceIndex()).arg(path()); + qCWarning(PROJECTEUR_HID_LOG).noquote() << QStringLiteral("Invalid device index (%1) in message for '%2'").arg(msg.deviceIndex()).arg(path()); if (cb) { cb(MsgResult::InvalidFormat, HIDPP::Message()); } return; } @@ -171,7 +184,7 @@ void SubHidppConnection::sendRequest(HIDPP::Message msg, RequestResultCallback r [&msg](const RequestEntry& entry) { return entry.request == msg; }); if (it == m_requests.end()) { - logDebug(hid) << "Send request write error without matching request queue entry."; + qCDebug(PROJECTEUR_HID_LOG).noquote() << "Send request write error without matching request queue entry."; return; } @@ -367,26 +380,56 @@ void SubHidppConnection::sendVibrateCommand(uint8_t intensity, uint8_t length, { const uint8_t pcIndex = m_featureSet.featureIndex(HIDPP::FeatureCode::PresenterControl); - if (pcIndex == 0) + if (pcIndex != 0) { - if (cb) { cb(MsgResult::FeatureNotSupported, HIDPP::Message()); } - return; - } + // Original Logitech Spotlight vibration protocol. + length = length > 10 ? 10 : length; // length should be between 0 to 10. - // Logitech Spotlight: - // present - // controlID len intensity - // unsigned char vibrate[] = {0x10, 0x01, 0x09, 0x1d, 0x00, 0xe8, 0x80}; + using namespace HIDPP; + Message vibrateMsg(Message::Type::Long, m_deviceIndex, pcIndex, 1, { + length, 0xe8, intensity + }); - length = length > 10 ? 10 : length; // length should be between 0 to 10. + sendRequest(std::move(vibrateMsg), std::move(cb)); + return; + } using namespace HIDPP; + const uint8_t hapticIndex = m_featureSet.featureIndex(FeatureCode::Haptic); + if (hapticIndex == 0) + { + if (cb) { cb(MsgResult::FeatureNotSupported, Message()); } + return; + } - Message vibrateMsg(Message::Type::Long, DeviceIndex::WirelessDevice1, pcIndex, 1, { - length, 0xe8, intensity + // Spotlight 2 uses the newer HAPTIC feature. Its level is a global percentage + // (setHapticLevel, function 2), and feedback is produced by asking the device + // to play one of its built-in waveforms (playWaveform, function 4). + // + // "Completed" (0x07) is a concise notification waveform suitable for the + // existing vibration timers. The legacy length has no HAPTIC equivalent. + constexpr uint8_t completedWaveform = 0x07; + constexpr uint8_t defaultDisabledLevel = 50; + const bool enabled = intensity != 0; + const uint8_t level = enabled ? hapticLevelFromIntensity(intensity) + : defaultDisabledLevel; + + Message setLevelMsg(Message::Type::Long, m_deviceIndex, hapticIndex, 2, { + static_cast(enabled), level }); - sendRequest(std::move(vibrateMsg), std::move(cb)); + sendRequest(std::move(setLevelMsg), makeSafeCallback( + [this, hapticIndex, cb=std::move(cb)](MsgResult result, Message&& response) mutable + { + if (result != MsgResult::Ok) { + if (cb) { cb(result, std::move(response)); } + return; + } + + Message playMsg(Message::Type::Long, m_deviceIndex, hapticIndex, 4, + Message::Data{completedWaveform}); + sendRequest(std::move(playMsg), std::move(cb)); + })); } // ------------------------------------------------------------------------------------------------- @@ -395,22 +438,32 @@ void SubHidppConnection::getBatteryLevelStatus( { using namespace HIDPP; - const auto batteryIndex = m_featureSet.featureIndex(FeatureCode::BatteryStatus); + const auto batteryStatusIndex = m_featureSet.featureIndex(FeatureCode::BatteryStatus); + const auto unifiedBatteryIndex = m_featureSet.featureIndex(FeatureCode::UnifiedBattery); + const auto batteryIndex = batteryStatusIndex != 0 ? batteryStatusIndex : unifiedBatteryIndex; if (batteryIndex == 0) { if (cb) { cb(MsgResult::FeatureNotSupported, {}); } return; } - Message batteryReqMsg(Message::Type::Short, DeviceIndex::WirelessDevice1, batteryIndex, 0); - sendRequest(std::move(batteryReqMsg), [cb=std::move(cb)](MsgResult res, Message&& msg) mutable + // BATTERY_STATUS uses getBatteryLevelStatus (function 0), whereas + // UNIFIED_BATTERY uses getStatus (function 1). + const uint8_t function = batteryStatusIndex != 0 ? 0 : 1; + Message batteryReqMsg( + Message::Type::Short, m_deviceIndex, batteryIndex, function); + sendRequest(std::move(batteryReqMsg), + [cb=std::move(cb), unified=unifiedBatteryIndex != 0 && batteryStatusIndex == 0] + (MsgResult res, Message&& msg) mutable { if (!cb) { return; } - auto batteryInfo = (res != MsgResult::Ok) ? BatteryInfo{} - : BatteryInfo{msg[4], - msg[5], - to_enum(msg[6])}; + // UNIFIED_BATTERY returns the exact discharge percentage, an approximate + // level, and the same status byte as BATTERY_STATUS. It has no "next + // reported percentage", so use the current percentage for both fields. + auto batteryInfo = (res != MsgResult::Ok) + ? BatteryInfo{} + : BatteryInfo{msg[4], unified ? msg[4] : msg[5], to_enum(msg[6])}; cb(res, std::move(batteryInfo)); }); } @@ -431,7 +484,7 @@ void SubHidppConnection::setPointerSpeed(uint8_t speed, const uint8_t pointerSpeed = 0x10 & speed; sendRequest( - HIDPP::Message(HIDPP::Message::Type::Long, HIDPP::DeviceIndex::WirelessDevice1, + HIDPP::Message(HIDPP::Message::Type::Long, m_deviceIndex, psIndex, 1, HIDPP::Message::Data{pointerSpeed}), std::move(cb) ); @@ -442,8 +495,7 @@ void SubHidppConnection::setReceiverState(ReceiverState rs) { if (rs == m_receiverState) { return; } - logDebug(hid) << tr("Receiver state (%1) changes from %3 to %4") - .arg(path()).arg(toString(m_receiverState), toString(rs)); + qCDebug(PROJECTEUR_HID_LOG).noquote() << QStringLiteral("Receiver state (%1) changes from %2 to %3").arg(path()).arg(toString(m_receiverState)).arg(toString(rs)); m_receiverState = rs; emit receiverStateChanged(m_receiverState); } @@ -453,8 +505,7 @@ void SubHidppConnection::setPresenterState(PresenterState ps) { if (ps == m_presenterState) { return; } - logDebug(hid) << tr("Presenter state (%1) changes from %2 to %3") - .arg(path()).arg(toString(m_presenterState), toString(ps)); + qCDebug(PROJECTEUR_HID_LOG).noquote() << QStringLiteral("Presenter state (%1) changes from %2 to %3").arg(path()).arg(toString(m_presenterState)).arg(toString(ps)); m_presenterState = ps; emit presenterStateChanged(m_presenterState); } @@ -475,7 +526,7 @@ void SubHidppConnection::initReceiver(std::function cb) if (m_receiverState == ReceiverState::Initializing || m_receiverState == ReceiverState::Initialized) { - logDebug(hid) << "Cannot init receiver when initializing or already initialized."; + qCDebug(PROJECTEUR_HID_LOG).noquote() << "Cannot init receiver when initializing or already initialized."; if (cb) { cb(m_receiverState); } return; } @@ -501,8 +552,7 @@ void SubHidppConnection::initReceiver(std::function cb) Message(Type::Short, DeviceIndex::DefaultDevice, Commands::GetRegister, 0, 0, {}), [index=++index](MsgResult result, HIDPP::Message&& /* msg */) { if (result == MsgResult::Ok) { return; } - logWarn(hid) << tr("Usb receiver init error; step %1: %2") - .arg(index).arg(toString(result)); + qCWarning(PROJECTEUR_HID_LOG).noquote() << QStringLiteral("Usb receiver init error; step %1: %2").arg(index).arg(toString(result)); } }, RequestBatchItem{ @@ -511,8 +561,7 @@ void SubHidppConnection::initReceiver(std::function cb) {0x00, 0x01, 0x00}), [index=++index](MsgResult result, HIDPP::Message&& /* msg */) { if (result == MsgResult::Ok) { return; } - logWarn(hid) << tr("Usb receiver init error; step %1: %2") - .arg(index).arg(toString(result)); + qCWarning(PROJECTEUR_HID_LOG).noquote() << QStringLiteral("Usb receiver init error; step %1: %2").arg(index).arg(toString(result)); } }, RequestBatchItem{ @@ -520,8 +569,7 @@ void SubHidppConnection::initReceiver(std::function cb) Message(Type::Short, DeviceIndex::DefaultDevice, Commands::GetRegister, 0, 2, {}), [index=++index](MsgResult result, HIDPP::Message&& /* msg */) { if (result == MsgResult::Ok) { return; } - logWarn(hid) << tr("Usb receiver init error; step %1: %2") - .arg(index).arg(toString(result)); + qCWarning(PROJECTEUR_HID_LOG).noquote() << QStringLiteral("Usb receiver init error; step %1: %2").arg(index).arg(toString(result)); } }, RequestBatchItem{ @@ -530,8 +578,7 @@ void SubHidppConnection::initReceiver(std::function cb) {0x02, 0x00, 0x00}), [index=++index](MsgResult result, HIDPP::Message&& /* msg */) { if (result == MsgResult::Ok) { return; } - logWarn(hid) << tr("Usb receiver init error; step %1: %2") - .arg(index).arg(toString(result)); + qCWarning(PROJECTEUR_HID_LOG).noquote() << QStringLiteral("Usb receiver init error; step %1: %2").arg(index).arg(toString(result)); } }, RequestBatchItem{ @@ -540,8 +587,7 @@ void SubHidppConnection::initReceiver(std::function cb) {0x00, 0x09, 0x00}), [index=++index](MsgResult result, HIDPP::Message&& /* msg */) { if (result == MsgResult::Ok) { return; } - logWarn(hid) << tr("Usb receiver init error; step %1: %2") - .arg(index).arg(toString(result)); + qCWarning(PROJECTEUR_HID_LOG).noquote() << QStringLiteral("Usb receiver init error; step %1: %2").arg(index).arg(toString(result)); } }, }}; @@ -564,7 +610,7 @@ void SubHidppConnection::initPresenter(std::function cb) || m_presenterState == PresenterState::Initialized_Offline || m_presenterState == PresenterState::Initialized_Online) { - logDebug(hid) << "Cannot init presenter when offline, initializing or already initialized."; + qCDebug(PROJECTEUR_HID_LOG).noquote() << "Cannot init presenter when offline, initializing or already initialized."; if (cb) { cb(m_presenterState); } return; } @@ -583,14 +629,16 @@ void SubHidppConnection::initPresenter(std::function cb) } case FState::Uninitialized: case FState::Initializing: { - logError(hid) << tr("Unexpected state from feature set."); + qCCritical(PROJECTEUR_HID_LOG).noquote() << QStringLiteral("Unexpected state from feature set."); setPresenterState(PresenterState::Error); break; } case FState::Initialized: { - logDebug(hid) << tr("Received %1 supported features from device. (%2)") - .arg(m_featureSet.featureCount()).arg(path()); + qCDebug(PROJECTEUR_HID_LOG).noquote() + << QStringLiteral("Received %1 supported features from device. (%2)") + .arg(m_featureSet.featureCount()) + .arg(path()); registerForFeatureNotifications(); updateDeviceFlags(); @@ -599,7 +647,7 @@ void SubHidppConnection::initPresenter(std::function cb) { if (!resultMap.empty()) { for (const auto& res : resultMap) { - logDebug(hid) << tr("InitFeature result %1 => %2").arg(toString(res.first)).arg(toString(res.second)); + qCDebug(PROJECTEUR_HID_LOG).noquote() << QStringLiteral("InitFeature result %1 => %2").arg(toString(res.first)).arg(toString(res.second)); } } emit featureSetInitialized(); @@ -628,7 +676,7 @@ void SubHidppConnection::initFeatures( if (const auto resetFeatureIndex = m_featureSet.featureIndex(FeatureCode::Reset)) { batch.emplace(RequestBatchItem { - Message(Message::Type::Long, DeviceIndex::WirelessDevice1, resetFeatureIndex, 1), + Message(Message::Type::Long, m_deviceIndex, resetFeatureIndex, 1), [resultMap](MsgResult res, Message&& /* msg */) { resultMap->emplace(FeatureCode::Reset, res); } @@ -641,7 +689,7 @@ void SubHidppConnection::initFeatures( if (hasFlags(DeviceFlags::NextHold)) { batch.emplace(RequestBatchItem { - Message(Message::Type::Long, DeviceIndex::WirelessDevice1, contrFeatureIndex, 3, + Message(Message::Type::Long, m_deviceIndex, contrFeatureIndex, 3, Message::Data{0x00, 0xda, 0x33}), [resultMap](MsgResult res, Message&& /* msg */) { resultMap->emplace(FeatureCode::ReprogramControlsV4, res); @@ -652,7 +700,7 @@ void SubHidppConnection::initFeatures( if (hasFlags(DeviceFlags::BackHold)) { batch.emplace(RequestBatchItem { - Message(Message::Type::Long, DeviceIndex::WirelessDevice1, contrFeatureIndex, 3, + Message(Message::Type::Long, m_deviceIndex, contrFeatureIndex, 3, Message::Data{0x00, 0xdc, 0x33}), [resultMap](MsgResult res, Message&& /* msg */) { resultMap->emplace(FeatureCode::ReprogramControlsV4, res); @@ -665,7 +713,7 @@ void SubHidppConnection::initFeatures( { // Reset pointer speed to 0x14 - the device accepts values from 0x10 to 0x19 batch.emplace(RequestBatchItem { - HIDPP::Message(HIDPP::Message::Type::Long, HIDPP::DeviceIndex::WirelessDevice1, + HIDPP::Message(HIDPP::Message::Type::Long, m_deviceIndex, psFeatureIndex, 1, HIDPP::Message::Data{0x14}), [resultMap](MsgResult res, Message&& /* msg */) { resultMap->emplace(FeatureCode::PointerSpeed, res); @@ -685,18 +733,28 @@ void SubHidppConnection::updateDeviceFlags() DeviceFlags featureFlagsSet = DeviceFlag::NoFlags; DeviceFlags featureFlagsUnset = DeviceFlag::NoFlags; - if (m_featureSet.featureCodeSupported(HIDPP::FeatureCode::PresenterControl)) { + const bool hasPresenterControl = + m_featureSet.featureCodeSupported(HIDPP::FeatureCode::PresenterControl); + const bool hasHaptic = + m_featureSet.featureCodeSupported(HIDPP::FeatureCode::Haptic); + if (hasPresenterControl || hasHaptic) { featureFlagsSet |= DeviceFlag::Vibrate; - logDebug(hid) << tr("Subdevice '%1' reported %2 support.") - .arg(path()).arg(toString(HIDPP::FeatureCode::PresenterControl)); + qCDebug(PROJECTEUR_HID_LOG).noquote() << QStringLiteral("Subdevice '%1' reported %2 support.").arg(path()).arg(toString(hasPresenterControl + ? HIDPP::FeatureCode::PresenterControl + : HIDPP::FeatureCode::Haptic)); } else { featureFlagsUnset |= DeviceFlag::Vibrate; } - if (m_featureSet.featureCodeSupported(HIDPP::FeatureCode::BatteryStatus)) { + const bool hasBatteryStatus = + m_featureSet.featureCodeSupported(HIDPP::FeatureCode::BatteryStatus); + const bool hasUnifiedBattery = + m_featureSet.featureCodeSupported(HIDPP::FeatureCode::UnifiedBattery); + if (hasBatteryStatus || hasUnifiedBattery) { featureFlagsSet |= DeviceFlag::ReportBattery; - logDebug(hid) << tr("Subdevice '%1' reported %2 support.") - .arg(path()).arg(toString(HIDPP::FeatureCode::BatteryStatus)); + qCDebug(PROJECTEUR_HID_LOG).noquote() << QStringLiteral("Subdevice '%1' reported %2 support.").arg(path()).arg(toString(hasBatteryStatus + ? HIDPP::FeatureCode::BatteryStatus + : HIDPP::FeatureCode::UnifiedBattery)); } else { featureFlagsUnset |= DeviceFlag::ReportBattery; } @@ -707,8 +765,7 @@ void SubHidppConnection::updateDeviceFlags() featureFlagsSet |= DeviceFlags::BackHold; specialMoveInputs.emplace_back(SpecialKeys::eventSequenceInfo(SpecialKeys::Key::NextHoldMove)); specialMoveInputs.emplace_back(SpecialKeys::eventSequenceInfo(SpecialKeys::Key::BackHoldMove)); - logDebug(hid) << tr("Subdevice '%1' reported %2 support.") - .arg(path()).arg(toString(HIDPP::FeatureCode::ReprogramControlsV4)); + qCDebug(PROJECTEUR_HID_LOG).noquote() << QStringLiteral("Subdevice '%1' reported %2 support.").arg(path()).arg(toString(HIDPP::FeatureCode::ReprogramControlsV4)); } else { featureFlagsUnset |= DeviceFlags::NextHold; @@ -718,8 +775,7 @@ void SubHidppConnection::updateDeviceFlags() if (m_featureSet.featureCodeSupported(HIDPP::FeatureCode::PointerSpeed)) { featureFlagsSet |= DeviceFlags::PointerSpeed; - logDebug(hid) << tr("Subdevice '%1' reported %2 support.") - .arg(path()).arg(toString(HIDPP::FeatureCode::PointerSpeed)); + qCDebug(PROJECTEUR_HID_LOG).noquote() << QStringLiteral("Subdevice '%1' reported %2 support.").arg(path()).arg(toString(HIDPP::FeatureCode::PointerSpeed)); } else { featureFlagsUnset |= DeviceFlags::BackHold; @@ -749,8 +805,7 @@ void SubHidppConnection::registerForFeatureNotifications() constexpr uint8_t ButtonBack = 0xdc; const auto isNextPressed = msg[5] == ButtonNext || msg[7] == ButtonNext; const auto isBackPressed = msg[5] == ButtonBack || msg[7] == ButtonBack; - logDebug(hid) << tr("Buttons pressed: Next = %1, Back = %2") - .arg(isNextPressed).arg(isBackPressed); + qCDebug(PROJECTEUR_HID_LOG).noquote() << QStringLiteral("Buttons pressed: Next = %1, Back = %2").arg(isNextPressed).arg(isBackPressed); }), 0 /* function 0 */); @@ -771,6 +826,13 @@ void SubHidppConnection::registerForFeatureNotifications() registerNotificationCallback(this, batIndex, makeSafeCallback([this](Message&& msg) { setBatteryInfo(BatteryInfo{msg[4], msg[5], to_enum(msg[6])}); }), 0 /* function 0 */); } + + if (const auto batIndex = m_featureSet.featureIndex(FeatureCode::UnifiedBattery)) + { + registerNotificationCallback(this, batIndex, makeSafeCallback([this](Message&& msg) { + setBatteryInfo(BatteryInfo{msg[4], msg[4], to_enum(msg[6])}); + }), 0 /* function 0 */); + } } // ------------------------------------------------------------------------------------------------- @@ -780,15 +842,29 @@ void SubHidppConnection::registerForUsbNotifications() registerNotificationCallback(this, HIDPP::Notification::DeviceConnection, makeSafeCallback( [this](HIDPP::Message&& msg) { + const auto notificationDeviceIndex = msg.deviceIndex(); + const auto deviceKind = msg[4] & 0x0f; const bool linkEstablished = !static_cast(msg[4] & (1<<6)); - logDebug(hid) << tr("%1, link established = %2") - .arg(toString(HIDPP::Notification::DeviceConnection)).arg(linkEstablished); + qCDebug(PROJECTEUR_HID_LOG).noquote() << QStringLiteral("%1, device index = %2, device kind = %3, link established = %4").arg(toString(HIDPP::Notification::DeviceConnection)).arg(notificationDeviceIndex).arg(deviceKind).arg(linkEstablished); + + if (!m_deviceIndexKnown && deviceKind == 0x04) + { + m_deviceIndex = notificationDeviceIndex; + m_deviceIndexKnown = true; + qCInfo(PROJECTEUR_HID_LOG).noquote() << QStringLiteral("Found presenter in Bolt receiver slot %1.").arg(m_deviceIndex); + } + + // A Bolt receiver can carry several devices. Ignore notifications that do + // not belong to the presenter selected above. + if (!m_deviceIndexKnown || notificationDeviceIndex != m_deviceIndex) { + return; + } if (!linkEstablished) { if (m_presenterState == PresenterState::Initialized_Online) { setPresenterState(PresenterState::Initialized_Offline); } - logInfo(hid) << tr("HID++ device '%1' went offline.").arg(path()); + qCInfo(PROJECTEUR_HID_LOG).noquote() << QStringLiteral("HID++ device '%1' went offline.").arg(path()); return; } @@ -797,7 +873,7 @@ void SubHidppConnection::registerForUsbNotifications() || m_presenterState == PresenterState::Uninitialized || m_presenterState == PresenterState::Error) { - logInfo(hid) << tr("HID++ device '%1' came online.").arg(path()); + qCInfo(PROJECTEUR_HID_LOG).noquote() << QStringLiteral("HID++ device '%1' came online.").arg(path()); checkAndUpdatePresenterState(makeSafeCallback([](PresenterState /* ps */) { //... })); @@ -816,10 +892,13 @@ void SubHidppConnection::subDeviceInit() initReceiver(makeSafeCallback([this](ReceiverState rs) { Q_UNUSED(rs); - // Independent of the receiver init result, try to initialize the - // presenter device HID++ features and more - checkAndUpdatePresenterState(makeSafeCallback([](PresenterState /* ps */) { - //... + // Independent of the receiver init result, find the presenter slot and + // initialize the device HID++ features. + findPresenterDeviceIndex(HIDPP::DeviceIndex::WirelessDevice1, + makeSafeCallback([this](bool /* found */) { + checkAndUpdatePresenterState(makeSafeCallback([](PresenterState /* ps */) { + //... + })); })); })); } @@ -860,13 +939,51 @@ const HIDPP::BatteryInfo& SubHidppConnection::batteryInfo() const { // ------------------------------------------------------------------------------------------------- void SubHidppConnection::sendPing(RequestResultCallback cb) +{ + sendPing(m_deviceIndex, std::move(cb)); +} + +// ------------------------------------------------------------------------------------------------- +void SubHidppConnection::sendPing(uint8_t deviceIndex, RequestResultCallback cb) { using namespace HIDPP; - // Ping wireless device 1 - same as requesting protocol version - Message pingMsg(Message::Type::Short, DeviceIndex::WirelessDevice1, 0, 1, getRandomPingPayload()); + // A HID++ root ping also returns the device protocol version. + Message pingMsg(Message::Type::Short, deviceIndex, 0, 1, getRandomPingPayload()); sendRequest(std::move(pingMsg), std::move(cb)); } +// ------------------------------------------------------------------------------------------------- +void SubHidppConnection::findPresenterDeviceIndex(uint8_t candidate, + std::function cb) +{ + if (m_deviceIndexKnown) + { + if (cb) { cb(true); } + return; + } + + if (candidate > HIDPP::DeviceIndex::WirelessDevice6) + { + if (cb) { cb(false); } + return; + } + + sendPing(candidate, makeSafeCallback( + [this, candidate, cb=std::move(cb)](MsgResult result, HIDPP::Message&& /* msg */) mutable + { + if (result == MsgResult::Ok) + { + m_deviceIndex = candidate; + m_deviceIndexKnown = true; + qCInfo(PROJECTEUR_HID_LOG).noquote() << QStringLiteral("Found HID++ device in Bolt receiver slot %1.").arg(m_deviceIndex); + if (cb) { cb(true); } + return; + } + + findPresenterDeviceIndex(candidate + 1, std::move(cb)); + })); +} + // ------------------------------------------------------------------------------------------------- void SubHidppConnection::getProtocolVersion(std::function cb) @@ -875,8 +992,7 @@ void SubHidppConnection::getProtocolVersion(std::function %1, version = %2.%3") - .arg(toString(res)).arg(pv.major).arg(pv.minor); + qCDebug(PROJECTEUR_HID_LOG).noquote() << QStringLiteral("getProtocolVersion() => %1, version = %2.%3").arg(toString(res)).arg(pv.major).arg(pv.minor); cb(res, (res == MsgResult::HidppError) ? msg.errorCode() : HIDPP::Error::NoError, pv); } @@ -892,8 +1008,7 @@ void SubHidppConnection::checkPresenterOnline(std::function %2").arg(toString(res.first)).arg(toString(res.second)); + qCDebug(PROJECTEUR_HID_LOG).noquote() << QStringLiteral("InitFeature result %1 => %2").arg(toString(res.first)).arg(toString(res.second)); } } setPresenterState(PresenterState::Initialized_Online); @@ -992,7 +1107,7 @@ void SubHidppConnection::onHidppDataAvailable(int fd) // just ignore regular HID reports from the Logitech Spotlight } else { - logDebug(hid) << tr("Received invalid HID++ message '%1' from %2").arg(msg.hex(), path()); + qCDebug(PROJECTEUR_HID_LOG).noquote() << QStringLiteral("Received invalid HID++ message '%1' from %2").arg(msg.hex()).arg(path()); } return; } @@ -1006,16 +1121,15 @@ void SubHidppConnection::onHidppDataAvailable(int fd) if (it != m_requests.end()) { - logDebug(hid) << tr("Received hiddpp error with code = %1 on") - .arg(to_integral(msg.errorCode())) << path() << "(" << msg.hex() << ")"; + qCDebug(PROJECTEUR_HID_LOG).noquote() << QStringLiteral("Received hiddpp error with code = %1 on").arg(to_integral(msg.errorCode())) << path() << "(" << msg.hex() << ")"; if (it->callBack) { it->callBack(MsgResult::HidppError, std::move(msg)); } m_requests.erase(it); } else { - logWarn(hid) << tr("Received error hidpp message '%1' " - "without matching request.").arg(qPrintable(msg.hex())); + qCWarning(PROJECTEUR_HID_LOG).noquote() << QStringLiteral("Received error hidpp message '%1' " + "without matching request.").arg(msg.hex()); } return; } @@ -1029,8 +1143,8 @@ void SubHidppConnection::onHidppDataAvailable(int fd) if (it != m_requests.end()) { // Found matching request - logDebug(hid) << tr("Received %1 bytes on").arg(msg.size()) << path() - << "(" << msg.hex() << ")"; + qCDebug(PROJECTEUR_HID_LOG).noquote() << "Received" << msg.size() << "bytes on" << path() + << "(" << msg.hex() << ")"; if (it->callBack) { it->callBack(MsgResult::Ok, std::move(msg)); } @@ -1039,7 +1153,6 @@ void SubHidppConnection::onHidppDataAvailable(int fd) else if (msg.softwareId() == 0 || msg.subId() < 0x80) { // Event/Notification - // logDebug(hid) << tr("Received notification (%1) on %2").arg(msg.hex()).arg(path()); // Notify subscribers const auto& callbackList = m_notificationSubscribers[msg.featureIndex()]; @@ -1051,8 +1164,8 @@ void SubHidppConnection::onHidppDataAvailable(int fd) } else { - logWarn(hid) << tr("Received hidpp message " - "'%1' without matching request.").arg(msg.hex()); + qCWarning(PROJECTEUR_HID_LOG).noquote() << QStringLiteral("Received hidpp message " + "'%1' without matching request.").arg(msg.hex()); } } diff --git a/src/device-hidpp.h b/src/device-hidpp.h index bf9791c8..c8c5bd3b 100644 --- a/src/device-hidpp.h +++ b/src/device-hidpp.h @@ -41,6 +41,7 @@ class SubHidppConnection : public SubHidrawConnection, public HidppConnectionInt // --- HidppConnectionInterface implementation: BusType busType() const override { return m_details.deviceId.busType; } + uint8_t deviceIndex() const override { return m_deviceIndex; } ssize_t sendData(std::vector msg) override; ssize_t sendData(HIDPP::Message msg) override; void sendData(std::vector msg, SendResultCallback resultCb) override; @@ -106,6 +107,8 @@ class SubHidppConnection : public SubHidrawConnection, public HidppConnectionInt void checkAndUpdatePresenterState(std::function cb); void clearTimedOutRequests(); + void findPresenterDeviceIndex(uint8_t candidate, std::function cb); + void sendPing(uint8_t deviceIndex, RequestResultCallback cb); void sendDataBatch(DataBatch dataBatch, DataBatchResultCallback cb, bool continueOnError, std::vector results); @@ -115,6 +118,8 @@ class SubHidppConnection : public SubHidrawConnection, public HidppConnectionInt HIDPP::FeatureSet m_featureSet; HIDPP::ProtocolVersion m_protocolVersion; HIDPP::BatteryInfo m_batteryInfo; + uint8_t m_deviceIndex = HIDPP::DeviceIndex::WirelessDevice1; + bool m_deviceIndexKnown = true; ReceiverState m_receiverState = ReceiverState::Uninitialized; PresenterState m_presenterState = PresenterState::Uninitialized; diff --git a/src/device-key-lookup.cc b/src/device-key-lookup.cc index 68eff2c8..02dda592 100644 --- a/src/device-key-lookup.cc +++ b/src/device-key-lookup.cc @@ -5,6 +5,8 @@ #include "enum-helper.h" +#include + #include #include @@ -38,9 +40,9 @@ const QString& lookup(const DeviceId& dId, const DeviceInputEvent& die) using KeyNameMap = std::unordered_map; static const KeyNameMap logitechSpotlightMapping = { - { eHash(EV_KEY, BTN_LEFT), QObject::tr("Click") }, - { eHash(EV_KEY, KEY_RIGHT), QObject::tr("Next") }, - { eHash(EV_KEY, KEY_LEFT), QObject::tr("Back") }, + { eHash(EV_KEY, BTN_LEFT), i18n("Click") }, + { eHash(EV_KEY, KEY_RIGHT), i18n("Next") }, + { eHash(EV_KEY, KEY_LEFT), i18n("Back") }, { eHash(EV_KEY, to_integral(SpecialKeys::Key::NextHold)), SpecialKeys::eventSequenceInfo(SpecialKeys::Key::NextHold).name }, { eHash(EV_KEY, to_integral(SpecialKeys::Key::BackHold)), @@ -48,15 +50,16 @@ const QString& lookup(const DeviceId& dId, const DeviceInputEvent& die) }; static const KeyNameMap avattoH100Mapping = { - { eHash(EV_KEY, BTN_LEFT), QObject::tr("Click") }, - { eHash(EV_KEY, KEY_PAGEDOWN), QObject::tr("Down") }, - { eHash(EV_KEY, KEY_PAGEUP), QObject::tr("Up") }, + { eHash(EV_KEY, BTN_LEFT), i18n("Click") }, + { eHash(EV_KEY, KEY_PAGEDOWN), i18n("Down") }, + { eHash(EV_KEY, KEY_PAGEUP), i18n("Up") }, }; static const std::unordered_map map = { {dHash({0x046d, 0xc53e}), logitechSpotlightMapping}, // Spotlight USB {dHash({0x046d, 0xb503}), logitechSpotlightMapping}, // Spotlight Bluetooth + {dHash({0x046d, 0xb506}), logitechSpotlightMapping}, // Spotlight 2 Bluetooth {dHash({0x0c45, 0x8101}), avattoH100Mapping}, // Avatto H100, August WP200 }; @@ -75,4 +78,4 @@ const QString& lookup(const DeviceId& dId, const DeviceInputEvent& die) static const QString notFound; return notFound; } -} // end namespace KeyName \ No newline at end of file +} // end namespace KeyName diff --git a/src/device-vibration.cc b/src/device-vibration.cc deleted file mode 100644 index 18b29a94..00000000 --- a/src/device-vibration.cc +++ /dev/null @@ -1,446 +0,0 @@ -// This file is part of Projecteur - https://github.com/jahnf/projecteur -// - See LICENSE.md and README.md - -#include "device-vibration.h" - -#include "device-hidpp.h" -#include "hidpp.h" -#include "iconwidgets.h" -#include "logging.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include - -DECLARE_LOGGING_CATEGORY(hid) - -// ------------------------------------------------------------------------------------------------- -namespace { - constexpr uint32_t numTimers = 3; -} // end anonymous namespace - -// ------------------------------------------------------------------------------------------------- -struct TimerWidget::Impl -{ - // ----------------------------------------------------------------------------------------------- - explicit Impl(TimerWidget* parent) - : stack(new QStackedWidget(parent)) - , editor(new QWidget(parent)) - , overlay(new QWidget(parent)) - , checkbox(new QCheckBox(parent)) - , sbHours(new QSpinBox(parent)) - , sbMinutes(new QSpinBox(parent)) - , sbSeconds(new QSpinBox(parent)) - , btnStartStop(new IconButton(Font::Icon::media_control_48, parent)) - , timer(new QTimer(parent)) - , countdownTimer(new QTimer(parent)) - , overlayLabel(new QLabel(parent)) - { - const auto layout = new QHBoxLayout(parent); - layout->addWidget(checkbox); - layout->addWidget(stack); - layout->setContentsMargins(0, 0, 0, 0); - - stack->addWidget(editor); - stack->addWidget(overlay); - const auto editLayout = new QHBoxLayout(editor); - const auto m = editLayout->contentsMargins(); - editLayout->setContentsMargins(m.left(), 0, m.right(), 0); - editLayout->addWidget(sbHours); - editLayout->addWidget(new QLabel(TimerWidget::tr("h"), editor)); - editLayout->addWidget(sbMinutes); - editLayout->addWidget(new QLabel(TimerWidget::tr("m"), editor)); - editLayout->addWidget(sbSeconds); - editLayout->addWidget(new QLabel(TimerWidget::tr("s"), editor)); - editLayout->addStretch(1); - - constexpr auto day = std::chrono::hours(24); - constexpr auto hoursMax = (day - std::chrono::hours(1)).count(); - constexpr auto minutesMax = std::chrono::minutes(60).count() - 1; - constexpr auto secondsMax = std::chrono::seconds(60).count() - 1; - - sbHours->setRange(0, hoursMax); - sbMinutes->setRange(0, minutesMax); - sbSeconds->setRange(0, secondsMax); - - layout->addWidget(btnStartStop); - btnStartStop->setCheckable(true); - QObject::connect(btnStartStop, &IconButton::toggled, parent, [this](bool checked) { - stack->setCurrentWidget(checked ? overlay : editor); - btnStartStop->setText(checked ? QChar(Font::Icon::media_control_50) - : QChar(Font::Icon::media_control_48)); - if (checked) { - secondsLeft = valueSeconds(); - updateOverlayLabel(secondsLeft); - countdownTimer->start(); - timer->start(); - } else { - timer->stop(); - countdownTimer->stop(); - } - }); - - const auto overlayLayout = new QHBoxLayout(overlay); - overlayLayout->addWidget(overlayLabel); - overlayLayout->setContentsMargins(m.left(), 0, m.right(), 0); - overlayLabel->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont)); - - editor->setEnabled(checkbox->isChecked()); - btnStartStop->setEnabled(checkbox->isChecked()); - QObject::connect(checkbox, &QCheckBox::toggled, parent, [this, parent](bool checked) { - editor->setEnabled(checked); - if (!checked) { btnStartStop->setChecked(false); } - btnStartStop->setEnabled(checked); - emit parent->enabledChanged(checked); - }); - - QObject::connect(timer, &QTimer::timeout, parent, [this](){ btnStartStop->setChecked(false); }); - QObject::connect(sbHours, static_cast(&QSpinBox::valueChanged), parent, - [this, parent]() { - updateTimerInterval(); - emit parent->valueSecondsChanged(valueSeconds()); - }); - QObject::connect(sbMinutes, static_cast(&QSpinBox::valueChanged), parent, - [this, parent]() { - updateTimerInterval(); - emit parent->valueSecondsChanged(valueSeconds()); - }); - QObject::connect(sbSeconds, static_cast(&QSpinBox::valueChanged), parent, - [this, parent]() { - updateTimerInterval(); - emit parent->valueSecondsChanged(valueSeconds()); - }); - - timer->setSingleShot(true); - countdownTimer->setInterval(1000); - - QObject::connect(countdownTimer, &QTimer::timeout, parent, [this](){ - updateOverlayLabel(--secondsLeft); - }); - } - - int valueSeconds() const { - return sbSeconds->value() + sbMinutes->value() * 60 + sbHours->value() * 60 * 60; - } - - // ----------------------------------------------------------------------------------------------- - void updateTimerInterval() { - timer->setInterval(valueSeconds() * 1000); - } - - // ----------------------------------------------------------------------------------------------- - void updateOverlayLabel(int remainingSeconds) - { - const std::chrono::seconds remainingTime(remainingSeconds); - const auto hours = std::chrono::duration_cast(remainingTime); - const auto mins = std::chrono::duration_cast(remainingTime-hours); - const auto secs = std::chrono::duration_cast(remainingTime-hours-mins); - - overlayLabel->setText(QString("%1:%2:%3") - .arg(hours.count(), 2, 10, QChar('0')) - .arg(mins.count(), 2, 10, QChar('0')) - .arg(secs.count(), 2, 10, QChar('0'))); - } - - // ----------------------------------------------------------------------------------------------- - QStackedWidget* stack = nullptr; - QWidget* editor = nullptr; - QWidget* overlay = nullptr; - QCheckBox* checkbox = nullptr; - QSpinBox* sbHours = nullptr; - QSpinBox* sbMinutes = nullptr; - QSpinBox* sbSeconds = nullptr; - IconButton* btnStartStop = nullptr; - QTimer* timer = nullptr; - QTimer* countdownTimer = nullptr; - QLabel* overlayLabel = nullptr; - int secondsLeft = 0; -}; - -// ------------------------------------------------------------------------------------------------- -TimerWidget::TimerWidget(QWidget* parent) - : QWidget(parent) - , m_impl(new Impl(this)) -{ - connect(m_impl->timer, &QTimer::timeout, this, &TimerWidget::timeout); -} - -// ------------------------------------------------------------------------------------------------- -TimerWidget::~TimerWidget() = default; - -// ------------------------------------------------------------------------------------------------- -bool TimerWidget::timerEnabled() const { - return m_impl->checkbox->isChecked(); -} - -// ------------------------------------------------------------------------------------------------- -void TimerWidget::setTimerEnabled(bool enabled) { - m_impl->checkbox->setChecked(enabled); -} - -// ------------------------------------------------------------------------------------------------- -bool TimerWidget::timerRunning() const { - return m_impl->timer->isActive(); -} - -// ------------------------------------------------------------------------------------------------- -void TimerWidget::start() { - if (timerEnabled()) { - m_impl->btnStartStop->setChecked(true); - } -} - -// ------------------------------------------------------------------------------------------------- -void TimerWidget::stop() { - m_impl->btnStartStop->setChecked(false); -} - -// ------------------------------------------------------------------------------------------------- -void TimerWidget::setValueSeconds(int seconds) -{ - const std::chrono::seconds totalSecs(seconds); - const auto hours = std::chrono::duration_cast(totalSecs); - const auto mins = std::chrono::duration_cast(totalSecs-hours); - const auto secs = std::chrono::duration_cast(totalSecs-hours-mins); - m_impl->sbHours->setValue( static_cast(hours.count()) ); - m_impl->sbMinutes->setValue( static_cast(mins.count()) ); - m_impl->sbSeconds->setValue( static_cast(secs.count()) ); -} - -// ------------------------------------------------------------------------------------------------- -void TimerWidget::setValueMinutes(int minutes) { - setValueSeconds(minutes * 60); -} - -// ------------------------------------------------------------------------------------------------- -int TimerWidget::valueSeconds() const { - return m_impl->valueSeconds(); -} - -// ------------------------------------------------------------------------------------------------- -struct MultiTimerWidget::Impl -{ - explicit Impl(QWidget* parent) - { - for (size_t i = 0; i < numTimers; ++i) { - timers.at(i) = new TimerWidget(parent); - } - } - - std::array timers = {}; -}; - -// ------------------------------------------------------------------------------------------------- -MultiTimerWidget::MultiTimerWidget(QWidget* parent) - : QWidget(parent) - , m_impl(new Impl(this)) -{ - constexpr int defaultTimeoutIncrMin = 15; - - const auto layout = new QHBoxLayout(this); - const auto iconLabel = new IconLabel(Font::time_19, this); - layout->addWidget(iconLabel); - layout->setAlignment(iconLabel, Qt::AlignTop); - - const auto groupBox = new QGroupBox(tr("Timers"), this); - groupBox->setSizePolicy(groupBox->sizePolicy().horizontalPolicy(), - QSizePolicy::Maximum); - layout->addWidget(groupBox); - layout->setAlignment(groupBox, Qt::AlignTop); - const auto timerLayout = new QVBoxLayout(groupBox); - - for (uint32_t i = 0; i < numTimers; ++i) - { - timerLayout->addWidget(m_impl->timers.at(i)); - const auto timerDefaultValueMinutes = defaultTimeoutIncrMin + i * defaultTimeoutIncrMin; - - m_impl->timers.at(i)->setValueMinutes(static_cast(timerDefaultValueMinutes)); - - connect(m_impl->timers.at(i), &TimerWidget::valueSecondsChanged, this, [this, i](int secs) { - emit timerValueChanged(i, secs); - }); - - connect(m_impl->timers.at(i), &TimerWidget::enabledChanged, this, [this, i](bool enabled) { - emit timerEnabledChanged(i, enabled); - }); - - connect(m_impl->timers.at(i), &TimerWidget::timeout, this, [this, i](){ - emit timeout(i); - }); - } - - layout->setStretch(1, 1); -} - -// ------------------------------------------------------------------------------------------------- -MultiTimerWidget::~MultiTimerWidget() = default; - -// ------------------------------------------------------------------------------------------------- -int MultiTimerWidget::timerCount() { - return numTimers; -} - -// ------------------------------------------------------------------------------------------------- -void MultiTimerWidget::setTimerEnabled(uint32_t timerId, bool enabled) -{ - if (timerId >= numTimers) { return; } - m_impl->timers.at(timerId)->setTimerEnabled(enabled); -} - -// ------------------------------------------------------------------------------------------------- -bool MultiTimerWidget::timerEnabled(uint32_t timerId) const -{ - if (timerId >= numTimers) { return false; } - return m_impl->timers.at(timerId)->timerEnabled(); -} - -// ------------------------------------------------------------------------------------------------- -void MultiTimerWidget::startTimer(uint32_t timerId) -{ - if (timerId >= numTimers) { return; } - m_impl->timers.at(timerId)->start(); -} - -// ------------------------------------------------------------------------------------------------- -void MultiTimerWidget::stopTimer(uint32_t timerId) -{ - if (timerId >= numTimers) { return; } - m_impl->timers.at(timerId)->stop(); -} - -// ------------------------------------------------------------------------------------------------- -void MultiTimerWidget::stopAllTimers() -{ - for (size_t i = 0; i < numTimers; ++i) { - m_impl->timers.at(i)->stop(); - } -} - -// ------------------------------------------------------------------------------------------------- -bool MultiTimerWidget::timerRunning(uint32_t timerId) const -{ - if (timerId >= numTimers) { return false; } - return m_impl->timers.at(timerId)->timerRunning(); -} - -// ------------------------------------------------------------------------------------------------- -void MultiTimerWidget::setTimerValue(uint32_t timerId, int seconds) -{ - if (timerId >= numTimers) { return; } - m_impl->timers.at(timerId)->setValueSeconds(seconds); -} - -// ------------------------------------------------------------------------------------------------- -int MultiTimerWidget::timerValue(uint32_t timerId) const -{ - if (timerId >= numTimers) { return -1; } - return m_impl->timers.at(timerId)->valueSeconds(); -} - -// ------------------------------------------------------------------------------------------------- -VibrationSettingsWidget::VibrationSettingsWidget(QWidget* parent) - : QWidget(parent) - , m_sbLength(new QSpinBox(this)) - , m_sbIntensity(new QSpinBox(this)) -{ - constexpr int vibrationIntensityMin = 25; - constexpr int vibrationIntensityMax = 255; - - m_sbLength->setRange(0, 10); - m_sbIntensity->setRange(vibrationIntensityMin, vibrationIntensityMax); - - const auto layout = new QHBoxLayout(this); - const auto iconLabel = new IconLabel(Font::control_panel_9, this); - layout->addWidget(iconLabel); - layout->setAlignment(iconLabel, Qt::AlignTop); - - const auto groupBox = new QGroupBox(tr("Vibration Settings"), this); - groupBox->setSizePolicy(groupBox->sizePolicy().horizontalPolicy(), - QSizePolicy::Maximum); - layout->addWidget(groupBox); - layout->setAlignment(groupBox, Qt::AlignTop); - - const auto grid = new QGridLayout(groupBox); - grid->addWidget(new QLabel(tr("Length"), this), 0, 0); - grid->addWidget(new QLabel(tr("Intensity"), this), 1, 0); - grid->addWidget(m_sbLength, 0, 1); - grid->addWidget(m_sbIntensity, 1, 1); - grid->setColumnStretch(0, 1); - grid->setColumnStretch(1, 2); - - const auto testBtn = new QPushButton(tr("Test"), this); - grid->addWidget(testBtn, 2, 0, 1, 2); - - m_sbLength->setValue(0x00); - m_sbIntensity->setValue(0x80); - - connect(m_sbLength, static_cast(&QSpinBox::valueChanged), this, - [this](int value){ - emit lengthChanged(value); - }); - - connect(m_sbIntensity, static_cast(&QSpinBox::valueChanged), this, - [this](int value){ - emit intensityChanged(value); - }); - - connect(testBtn, &QPushButton::clicked, this, &VibrationSettingsWidget::sendVibrateCommand); - - layout->setStretch(1, 1); -} - -// ------------------------------------------------------------------------------------------------- -uint8_t VibrationSettingsWidget::length() const { - return m_sbLength->value(); -} - -// ------------------------------------------------------------------------------------------------- -uint8_t VibrationSettingsWidget::intensity() const { - return m_sbIntensity->value(); -} - -// ------------------------------------------------------------------------------------------------- -void VibrationSettingsWidget::setLength(uint8_t len) -{ - if (m_sbLength->value() == len) { return; } - m_sbLength->setValue(len); -} - -// ------------------------------------------------------------------------------------------------- -void VibrationSettingsWidget::setIntensity(uint8_t intensity) -{ - if (m_sbIntensity->value() == intensity) { return; } - m_sbIntensity->setValue(intensity); -} - -// ------------------------------------------------------------------------------------------------- -void VibrationSettingsWidget::setSubDeviceConnection(SubDeviceConnection *sdc) -{ - m_subDeviceConnection = qobject_cast(sdc); -} - -// ------------------------------------------------------------------------------------------------- -void VibrationSettingsWidget::sendVibrateCommand() -{ - if (!m_subDeviceConnection) { return; } - if (!m_subDeviceConnection->isConnected()) { return; } - if (!m_subDeviceConnection->hasFlags(DeviceFlag::Vibrate)) { return; } - - const uint8_t vlen = m_sbLength->value(); - const uint8_t vint = m_sbIntensity->value(); - m_subDeviceConnection->sendVibrateCommand(vint, vlen, - [](HidppConnectionInterface::MsgResult result, HIDPP::Message&& msg) { - logDebug(hid) << tr("Vibrate command returned: %1 (%2)") - .arg(toString(result)).arg(msg.hex()); - }); -} diff --git a/src/device-vibration.h b/src/device-vibration.h deleted file mode 100644 index 097a5c20..00000000 --- a/src/device-vibration.h +++ /dev/null @@ -1,101 +0,0 @@ -// This file is part of Projecteur - https://github.com/jahnf/projecteur -// - See LICENSE.md and README.md -#pragma once - -#include -#include -#include - -class QSpinBox; -class SubDeviceConnection; -class SubHidppConnection; - -// ------------------------------------------------------------------------------------------------- -class TimerWidget : public QWidget -{ - Q_OBJECT - -public: - TimerWidget(QWidget* parent); - ~TimerWidget() override; - - bool timerEnabled() const; - void setTimerEnabled(bool enabled); - - void start(); - void stop(); - bool timerRunning() const; - void setValueSeconds(int seconds); - void setValueMinutes(int minutes); - int valueSeconds() const; - -signals: - void timeout(); - void valueSecondsChanged(int); - void enabledChanged(bool); - -private: - struct Impl; - std::unique_ptr m_impl; -}; - -// ------------------------------------------------------------------------------------------------- -class MultiTimerWidget : public QWidget -{ - Q_OBJECT - -public: - explicit MultiTimerWidget(QWidget* parent = nullptr); - virtual ~MultiTimerWidget() override; - - /// Returns the number of timers - static int timerCount(); - - void setTimerEnabled(uint32_t timerId, bool enabled); - bool timerEnabled(uint32_t timerId) const; - - void startTimer(uint32_t timerId); - void stopTimer(uint32_t timerId); - void stopAllTimers(); - bool timerRunning(uint32_t timerId) const; - - void setTimerValue(uint32_t timerId, int seconds); - int timerValue(uint32_t timerId) const; - -signals: - /// Emitted when a timer times out. - void timeout(uint32_t timerId); - void timerEnabledChanged(uint32_t timerId, bool enabled); - void timerValueChanged(uint32_t timerId, int seconds); - -private: - struct Impl; - std::unique_ptr m_impl; -}; - -// ------------------------------------------------------------------------------------------------- -class VibrationSettingsWidget : public QWidget -{ - Q_OBJECT - -public: - explicit VibrationSettingsWidget(QWidget* parent = nullptr); - - uint8_t length() const; - void setLength(uint8_t len); - - uint8_t intensity() const; - void setIntensity(uint8_t intensity); - - void setSubDeviceConnection(SubDeviceConnection* sdc); - void sendVibrateCommand(); - -signals: - void intensityChanged(uint8_t intensity); - void lengthChanged(uint8_t length); - -private: - QPointer m_subDeviceConnection; - QSpinBox* m_sbLength = nullptr; - QSpinBox* m_sbIntensity = nullptr; -}; diff --git a/src/device.cc b/src/device.cc index 32db26a9..a86bfbb0 100644 --- a/src/device.cc +++ b/src/device.cc @@ -7,7 +7,8 @@ #include "devicescan.h" #include "enum-helper.h" #include "hidpp.h" -#include "logging.h" +#include "projecteur_device_debug.h" +#include "projecteur_hid_debug.h" #include #include @@ -16,17 +17,8 @@ #include #include -LOGGING_CATEGORY(device, "device") -LOGGING_CATEGORY(hid, "HID") - namespace { - // ----------------------------------------------------------------------------------------------- - #if (QT_VERSION < QT_VERSION_CHECK(6, 0, 0)) - const auto registeredComparator_ = QMetaType::registerComparators(); - #endif - - const auto hexId = logging::hexId; - // class i18n : public QObject {}; // for i18n and logging + const auto hexId = formatHexId; } // end anonymous namespace // ------------------------------------------------------------------------------------------------- @@ -102,9 +94,7 @@ bool DeviceConnection::removeSubDevice(const QString& path) if (find_it != m_subDeviceConnections.end()) { if (find_it->second) { find_it->second->disconnect(); } // Important - logDebug(device) << tr("Disconnected sub-device: %1 (%2:%3) %4") - .arg(m_deviceName, hexId(m_deviceId.vendorId), - hexId(m_deviceId.productId), path); + qCDebug(PROJECTEUR_DEVICE_LOG).noquote() << QStringLiteral("Disconnected sub-device: %1 (%2:%3) %4").arg(m_deviceName).arg(hexId(m_deviceId.vendorId)).arg(hexId(m_deviceId.productId)).arg(path); emit subDeviceDisconnected(m_deviceId, path); m_subDeviceConnections.erase(find_it); return true; @@ -202,7 +192,7 @@ std::shared_ptr SubEventConnection::create(const DeviceScan: const int evfd = ::open(sd.deviceFile.toLocal8Bit().constData(), O_RDONLY, 0); if (evfd == -1) { - logWarn(device) << tr("Cannot open event device '%1' for read.").arg(sd.deviceFile); + qCWarning(PROJECTEUR_DEVICE_LOG).noquote() << QStringLiteral("Cannot open event device '%1' for read.").arg(sd.deviceFile); return std::shared_ptr(); } @@ -213,8 +203,7 @@ std::shared_ptr SubEventConnection::create(const DeviceScan: if (id.vendor != dc.deviceId().vendorId || id.product != dc.deviceId().productId) { ::close(evfd); - logDebug(device) << tr("Device id mismatch: %1 (%2:%3)") - .arg(sd.deviceFile, hexId(id.vendor), hexId(id.product)); + qCDebug(PROJECTEUR_DEVICE_LOG).noquote() << QStringLiteral("Device id mismatch: %1 (%2:%3)").arg(sd.deviceFile).arg(hexId(id.vendor)).arg(hexId(id.product)); return std::shared_ptr(); } @@ -222,8 +211,7 @@ std::shared_ptr SubEventConnection::create(const DeviceScan: if (ioctl(evfd, EVIOCGBIT(0, sizeof(bitmask)), &bitmask) < 0) { ::close(evfd); - logWarn(device) << tr("Cannot get device properties: %1 (%2:%3)") - .arg(sd.deviceFile, hexId(id.vendor), hexId(id.product)); + qCWarning(PROJECTEUR_DEVICE_LOG).noquote() << QStringLiteral("Cannot get device properties: %1 (%2:%3)").arg(sd.deviceFile).arg(hexId(id.vendor)).arg(hexId(id.product)); return std::shared_ptr(); } @@ -252,7 +240,7 @@ std::shared_ptr SubEventConnection::create(const DeviceScan: if (res == 0) { return true; } // Grab not successful - logError(device) << tr("Error grabbing device: %1 (return value: %2)").arg(sd.deviceFile).arg(res); + qCCritical(PROJECTEUR_DEVICE_LOG).noquote() << QStringLiteral("Error grabbing device: %1 (return value: %2)").arg(sd.deviceFile).arg(res); ioctl(evfd, EVIOCGRAB, 0); } return false; @@ -272,7 +260,7 @@ std::shared_ptr SubEventConnection::create(const DeviceScan: if (grabbed) { ioctl(evfd, EVIOCGRAB, 0); } - logDebug(device) << tr("Closing file descriptor for '%1'").arg(path); + qCDebug(PROJECTEUR_DEVICE_LOG).noquote() << QStringLiteral("Closing file descriptor for '%1'").arg(path); ::close(evfd); }); @@ -325,7 +313,7 @@ int SubHidrawConnection::openHidrawSubDevice(const DeviceScan::SubDevice& sd, co const int devfd = ::open(sd.deviceFile.toLocal8Bit().constData(), O_RDWR|O_NONBLOCK , 0); if (devfd == errorResult) { - logWarn(device) << tr("Cannot open hidraw device '%1' for read/write.").arg(sd.deviceFile); + qCWarning(PROJECTEUR_DEVICE_LOG).noquote() << QStringLiteral("Cannot open hidraw device '%1' for read/write.").arg(sd.deviceFile); return errorResult; } @@ -334,7 +322,7 @@ int SubHidrawConnection::openHidrawSubDevice(const DeviceScan::SubDevice& sd, co int descriptorSize = 0; if (ioctl(devfd, HIDIOCGRDESCSIZE, &descriptorSize) < 0) { - logWarn(device) << tr("Cannot retrieve report descriptor size of hidraw device '%1'.").arg(sd.deviceFile); + qCWarning(PROJECTEUR_DEVICE_LOG).noquote() << QStringLiteral("Cannot retrieve report descriptor size of hidraw device '%1'.").arg(sd.deviceFile); ::close(devfd); return errorResult; } @@ -343,7 +331,7 @@ int SubHidrawConnection::openHidrawSubDevice(const DeviceScan::SubDevice& sd, co reportDescriptor.size = descriptorSize; if (ioctl(devfd, HIDIOCGRDESC, &reportDescriptor) < 0) { - logWarn(device) << tr("Cannot retrieve report descriptor of hidraw device '%1'.").arg(sd.deviceFile); + qCWarning(PROJECTEUR_DEVICE_LOG).noquote() << QStringLiteral("Cannot retrieve report descriptor of hidraw device '%1'.").arg(sd.deviceFile); ::close(devfd); return errorResult; } @@ -353,7 +341,7 @@ int SubHidrawConnection::openHidrawSubDevice(const DeviceScan::SubDevice& sd, co // get the hidraw sub-device id info if (ioctl(devfd, HIDIOCGRAWINFO, &devinfo) < 0) { - logWarn(device) << tr("Cannot get info from hidraw device '%1'.").arg(sd.deviceFile); + qCWarning(PROJECTEUR_DEVICE_LOG).noquote() << QStringLiteral("Cannot get info from hidraw device '%1'.").arg(sd.deviceFile); ::close(devfd); return errorResult; }; @@ -362,8 +350,7 @@ int SubHidrawConnection::openHidrawSubDevice(const DeviceScan::SubDevice& sd, co if (static_cast(devinfo.vendor) != devId.vendorId || static_cast(devinfo.product) != devId.productId) { - logDebug(device) << tr("Device id mismatch: %1 (%2:%3)") - .arg(sd.deviceFile, hexId(devinfo.vendor), hexId(devinfo.product)); + qCDebug(PROJECTEUR_DEVICE_LOG).noquote() << QStringLiteral("Device id mismatch: %1 (%2:%3)").arg(sd.deviceFile).arg(hexId(devinfo.vendor)).arg(hexId(devinfo.product)); ::close(devfd); return errorResult; } @@ -386,10 +373,10 @@ ssize_t SubHidrawConnection::sendData(const void* msg, size_t msgLen) const auto res = ::write(m_writeNotifier->socket(), msg, msgLen); if (static_cast(res) == msgLen) { - logDebug(hid) << res << "bytes written to" << path() << "(" + qCDebug(PROJECTEUR_HID_LOG).noquote() << res << "bytes written to" << path() << "(" << QByteArray::fromRawData(static_cast(msg), msgLen).toHex() << ")"; } else { - logWarn(hid) << tr("Writing to '%1' failed. (%2)").arg(path()).arg(res); + qCWarning(PROJECTEUR_HID_LOG).noquote() << QStringLiteral("Writing to '%1' failed. (%2)").arg(path()).arg(res); } return res; @@ -412,7 +399,7 @@ void SubHidrawConnection::createSocketNotifiers(int fd, const QString& path) connect(readNotifier, &QSocketNotifier::destroyed, [fdPtr, path]() { if (fdPtr && *fdPtr != -1) { - logDebug(device) << tr("Closing file descriptor for '%1'").arg(path); + qCDebug(PROJECTEUR_DEVICE_LOG).noquote() << QStringLiteral("Closing file descriptor for '%1'").arg(path); ::close(*fdPtr); *fdPtr = -1; } @@ -425,7 +412,7 @@ void SubHidrawConnection::createSocketNotifiers(int fd, const QString& path) connect(writeNotifier, &QSocketNotifier::destroyed, [fdPtr, path]() { if (fdPtr && *fdPtr != -1) { - logDebug(device) << tr("Closing file descriptor for '%1'").arg(path); + qCDebug(PROJECTEUR_DEVICE_LOG).noquote() << QStringLiteral("Closing file descriptor for '%1'").arg(path); ::close(*fdPtr); *fdPtr = -1; } @@ -446,7 +433,7 @@ void SubHidrawConnection::onHidrawDataAvailable(int fd) // For generic hidraw devices without known protocols, just print out the // received data into the debug log - logDebug(hid) << "Received" << readVal.toHex() << "from" << path(); + qCDebug(PROJECTEUR_HID_LOG).noquote() << "Received" << readVal.toHex() << "from" << path(); } // ------------------------------------------------------------------------------------------------- diff --git a/src/deviceinput.cc b/src/deviceinput.cc index 7fc5a36e..1f0f3f7f 100644 --- a/src/deviceinput.cc +++ b/src/deviceinput.cc @@ -4,10 +4,12 @@ #include "deviceinput.h" #include "enum-helper.h" -#include "logging.h" +#include "projecteur_input_debug.h" #include "settings.h" #include "virtualdevice.h" +#include + #include #include #include @@ -16,15 +18,9 @@ #include -LOGGING_CATEGORY(input, "input") - namespace { - // ----------------------------------------------------------------------------------------------- - #if (QT_VERSION < QT_VERSION_CHECK(6, 0, 0)) - const auto registered_ = qRegisterMetaTypeStreamOperators() - && qRegisterMetaTypeStreamOperators(); - #endif - + const auto registeredMetaTypes_ = qRegisterMetaType() + && qRegisterMetaType(); // ----------------------------------------------------------------------------------------------- void addKeyToString(QString& str, const QString& key) @@ -393,11 +389,7 @@ QString NativeKeySequence::toString() const { if (i > 0) { seqString += QLatin1String(", "); } - #if (QT_VERSION < QT_VERSION_CHECK(6, 0, 0)) - const auto key = m_keySequence[i]; - #else const auto key = m_keySequence[i].key(); - #endif seqString += toString(key, (i < m_nativeModifiers.size()) ? m_nativeModifiers[i] @@ -627,13 +619,13 @@ void InputMapper::Impl::execAction(const std::shared_ptr& action, Device { if (!action || action->empty()) { return; } - logDebug(input) << "Input map execAction, type =" << toString(action->type()) + qCDebug(PROJECTEUR_INPUT_LOG).noquote() << "Input map execAction, type =" << toString(action->type()) << ", partial_hit =" << (r == DeviceKeyMap::Result::PartialHit); if (action->type() == Action::Type::KeySequence) { const auto keySequenceAction = static_cast(action.get()); - logDebug(input) << "Emitting Key Sequence:" << keySequenceAction->keySequence.toString(); + qCDebug(PROJECTEUR_INPUT_LOG).noquote() << "Emitting Key Sequence:" << keySequenceAction->keySequence.toString(); emitNativeKeySequence(keySequenceAction->keySequence); } else @@ -823,12 +815,12 @@ void InputMapper::addEvents(const input_event* input_events, size_t num) } if (input_events[num-1].type != EV_SYN) { - logWarning(input) << tr("Input mapper expects events separated by SYN event."); + qCWarning(PROJECTEUR_INPUT_LOG).noquote() << QStringLiteral("Input mapper expects events separated by SYN event."); return; } if (num == 1) { - logWarning(input) << tr("Ignoring single SYN event received."); + qCWarning(PROJECTEUR_INPUT_LOG).noquote() << QStringLiteral("Ignoring single SYN event received."); return; } @@ -845,7 +837,7 @@ void InputMapper::addEvents(const input_event* input_events, size_t num) if (impl->m_recordingMode) { - logDebug(input) << "Recorded device event:" << KeyEvent{input_events, input_events + num - 1}; + qCDebug(PROJECTEUR_INPUT_LOG).noquote() << "Recorded device event:" << KeyEvent{input_events, input_events + num - 1}; impl->record(input_events, num-1); // exclude closing syn event for recording return; } @@ -965,13 +957,13 @@ namespace SpecialKeys const std::map& keyEventSequenceMap() { static const std::map keyMap { - {Key::NextHold, {InputMapper::tr("Next Hold"), + {Key::NextHold, {i18n("Next Hold"), KeyEventSequence{{{EV_KEY, to_integral(Key::NextHold), 1}}}}}, - {Key::BackHold, {InputMapper::tr("Back Hold"), + {Key::BackHold, {i18n("Back Hold"), KeyEventSequence{{{EV_KEY, to_integral(Key::BackHold), 1}}}}}, - {Key::NextHoldMove, {InputMapper::tr("Next Hold Move"), + {Key::NextHoldMove, {i18n("Next Hold Move"), makeSpecialKeyEventSequence(to_integral(Key::NextHoldMove)) }}, - {Key::BackHoldMove, {InputMapper::tr("Back Hold Move"), + {Key::BackHoldMove, {i18n("Back Hold Move"), makeSpecialKeyEventSequence(to_integral(Key::BackHoldMove))}}, }; return keyMap; diff --git a/src/deviceinput.h b/src/deviceinput.h index 71c4547a..3ad55319 100644 --- a/src/deviceinput.h +++ b/src/deviceinput.h @@ -274,10 +274,9 @@ struct MappedAction bool operator==(const MappedAction& o) const; std::shared_ptr action; }; -Q_DECLARE_METATYPE(MappedAction); - QDataStream& operator>>(QDataStream& s, MappedAction& mia); QDataStream& operator<<(QDataStream& s, const MappedAction& mia); +Q_DECLARE_METATYPE(MappedAction); // ------------------------------------------------------------------------------------------------- class InputMapConfig : public std::map{}; diff --git a/src/devicescan.cc b/src/devicescan.cc index 32f1cc2d..076a3a32 100644 --- a/src/devicescan.cc +++ b/src/devicescan.cc @@ -3,6 +3,8 @@ #include "devicescan.h" +#include + #include #include @@ -16,13 +18,13 @@ bool isExtraDeviceSupported(quint16 vendorId, quint16 productId); QString getExtraDeviceName(quint16 vendorId, quint16 productId); namespace { - class DeviceScan_ : public QObject {}; // for i18n and logging - // ----------------------------------------------------------------------------------------------- // List of supported devices - const std::array supportedDefaultDevices {{ + const std::array supportedDefaultDevices {{ {0x46d, 0xc53e, false, "Logitech Spotlight (USB)"}, {0x46d, 0xb503, true, "Logitech Spotlight (Bluetooth)"}, + {0x46d, 0xc548, false, "Logitech Spotlight 2 (USB-C receiver)"}, + {0x46d, 0xb506, true, "Logitech Spotlight 2 (Bluetooth)"}, }}; // ----------------------------------------------------------------------------------------------- @@ -165,18 +167,19 @@ namespace DeviceScan { // ----------------------------------------------------------------------------------------------- ScanResult getDevices(const std::vector& additionalDevices) { - constexpr char hidDevicePath[] = "/sys/bus/hid/devices"; + const QString hidDevicePath = QStringLiteral("/sys/bus/hid/devices"); ScanResult result; const QFileInfo dpInfo(hidDevicePath); if (!dpInfo.exists()) { - result.errorMessages.push_back(DeviceScan_::tr("HID device path '%1' does not exist.").arg(hidDevicePath)); + result.errorMessages.push_back(i18n("HID device path '%1' does not exist.", hidDevicePath)); return result; } if (!dpInfo.isExecutable()) { - result.errorMessages.push_back(DeviceScan_::tr("HID device path '%1': Cannot list files.").arg(hidDevicePath)); + result.errorMessages.push_back( + i18n("HID device path '%1': Cannot list files.", hidDevicePath)); return result; } diff --git a/src/deviceswidget.cc b/src/deviceswidget.cc index 6cc57b54..3fba9a04 100644 --- a/src/deviceswidget.cc +++ b/src/deviceswidget.cc @@ -4,17 +4,19 @@ #include "deviceswidget.h" #include "device-hidpp.h" -#include "device-vibration.h" #include "deviceinput.h" #include "iconwidgets.h" #include "inputmapconfig.h" -#include "logging.h" #include "settings.h" #include "spotlight.h" +#include + #include +#include #include #include +#include #include #include #include @@ -24,11 +26,9 @@ #include #include -DECLARE_LOGGING_CATEGORY(preferences) - // ------------------------------------------------------------------------------------------------- namespace { - const auto hexId = logging::hexId; + const auto hexId = formatHexId; QString descriptionString(const QString& name, const DeviceId& id) { return QString("%1 (%2:%3) [%4]").arg(name, hexId(id.vendorId), hexId(id.productId), id.phys); @@ -36,15 +36,6 @@ namespace { const auto invalidDeviceId = DeviceId(); // vendorId = 0, productId = 0 - bool removeTab(QTabWidget* tabWidget, QWidget* widget) - { - const auto idx = tabWidget->indexOf(widget); - if (idx >= 0) { - tabWidget->removeTab(idx); - return true; - } - return false; - } } // end anonymous namespace // ------------------------------------------------------------------------------------------------- @@ -78,20 +69,6 @@ DeviceId DevicesWidget::currentDeviceId() const return qvariant_cast(m_devicesCombo->currentData()); } -// ------------------------------------------------------------------------------------------------- -TimerTabWidget* DevicesWidget::createTimerTabWidget(Settings* settings, Spotlight* spotlight) -{ - Q_UNUSED(spotlight); - const auto w = new TimerTabWidget(settings, this); - w->loadSettings(currentDeviceId()); - - connect(this, &DevicesWidget::currentDeviceChanged, this, [this](const DeviceId& dId) { - if (m_timerTabWidget) { m_timerTabWidget->loadSettings(dId); } - }); - - return w; -} - // ------------------------------------------------------------------------------------------------- QWidget* DevicesWidget::createDevicesWidget(Settings* settings, Spotlight* spotlight) { @@ -100,7 +77,7 @@ QWidget* DevicesWidget::createDevicesWidget(Settings* settings, Spotlight* spotl const auto devHLayout = new QHBoxLayout(); vLayout->addLayout(devHLayout); - devHLayout->addWidget(new QLabel(tr("Device"), dw)); + devHLayout->addWidget(new QLabel(i18n("Device"), dw)); devHLayout->addWidget(m_devicesCombo); devHLayout->setStretch(1, 1); @@ -109,21 +86,108 @@ QWidget* DevicesWidget::createDevicesWidget(Settings* settings, Spotlight* spotl m_tabWidget = new QTabWidget(dw); vLayout->addWidget(m_tabWidget); - m_tabWidget->addTab(createInputMapperWidget(settings, spotlight), tr("Input Mapping")); - m_timerTabWidget = createTimerTabWidget(settings, spotlight); + m_tabWidget->addTab(createInputMapperWidget(settings, spotlight), i18n("Input Mapping")); - updateTimerTab(spotlight); + m_timerFeedbackWidget = createTimerFeedbackWidget(settings); m_deviceDetailsTabWidget = createDeviceInfoWidget(spotlight); - m_tabWidget->addTab(m_deviceDetailsTabWidget, tr("Details")); + m_tabWidget->addTab(m_deviceDetailsTabWidget, i18n("Details")); - // Update the timer tab when the current device has changed + updateTimerFeedbackTab(spotlight); connect(this, &DevicesWidget::currentDeviceChanged, this, - [spotlight, this]() { updateTimerTab(spotlight); }); + [this, settings, spotlight](const DeviceId& deviceId) { + loadTimerFeedbackSettings(settings, deviceId); + updateTimerFeedbackTab(spotlight); + }); return dw; } +// ------------------------------------------------------------------------------------------------- +QWidget* DevicesWidget::createTimerFeedbackWidget(Settings* settings) +{ + const auto widget = new QWidget(this); + const auto group = new QGroupBox(i18n("Presentation timer feedback"), widget); + m_timerFeedbackStrength = new QSpinBox(group); + m_timerFeedbackStrength->setRange(0, 100); + m_timerFeedbackStrength->setSingleStep(5); + m_timerFeedbackStrength->setSuffix(i18n("%")); + m_timerFeedbackStrength->setToolTip(i18n("Set to 0% to disable completion vibration.")); + + const auto groupLayout = new QGridLayout(group); + groupLayout->addWidget(new QLabel(i18n("Completion vibration strength"), group), 0, 0); + groupLayout->addWidget(m_timerFeedbackStrength, 0, 1); + groupLayout->addWidget( + new QLabel(i18n("Vibrates this presenter when the presentation timer finishes."), group), + 1, 0, 1, 2); + groupLayout->setColumnStretch(1, 1); + + const auto layout = new QVBoxLayout(widget); + layout->addWidget(group); + layout->addStretch(1); + + loadTimerFeedbackSettings(settings, currentDeviceId()); + connect(m_timerFeedbackStrength, + static_cast(&QSpinBox::valueChanged), + this, [this, settings](int strength) { + settings->setDevicePresentationTimerHapticStrength(currentDeviceId(), strength); + }); + + return widget; +} + +// ------------------------------------------------------------------------------------------------- +void DevicesWidget::loadTimerFeedbackSettings(Settings* settings, const DeviceId& deviceId) +{ + if (!m_timerFeedbackStrength) { return; } + const QSignalBlocker blocker(m_timerFeedbackStrength); + m_timerFeedbackStrength->setValue( + settings->devicePresentationTimerHapticStrength(deviceId)); +} + +// ------------------------------------------------------------------------------------------------- +void DevicesWidget::updateTimerFeedbackTab(Spotlight* spotlight) +{ + const auto connection = spotlight->deviceConnection(currentDeviceId()); + bool supportsVibration = false; + if (connection) + { + for (const auto& item : connection->subDevices()) + { + const auto& subDevice = item.second; + if (subDevice && subDevice->hasFlags(DeviceFlag::Vibrate)) { + supportsVibration = true; + break; + } + + const auto hidpp = qobject_cast(subDevice.get()); + if (hidpp + && (hidpp->featureSet().featureCodeSupported(HIDPP::FeatureCode::PresenterControl) + || hidpp->featureSet().featureCodeSupported(HIDPP::FeatureCode::Haptic))) { + supportsVibration = true; + break; + } + } + } + + const int tabIndex = m_tabWidget->indexOf(m_timerFeedbackWidget); + if (supportsVibration && tabIndex < 0) { + m_tabWidget->insertTab(1, m_timerFeedbackWidget, i18n("Timer Feedback")); + } else if (!supportsVibration && tabIndex >= 0) { + m_tabWidget->removeTab(tabIndex); + } + + if (m_timerFeedbackContext) { m_timerFeedbackContext->deleteLater(); } + if (connection) + { + m_timerFeedbackContext = new QObject(this); + connect(connection.get(), &DeviceConnection::subDeviceFlagsChanged, m_timerFeedbackContext, + [this, spotlight](const DeviceId& id, const QString&) { + if (id == currentDeviceId()) { updateTimerFeedbackTab(spotlight); } + }); + } +} + // ------------------------------------------------------------------------------------------------- QWidget* DevicesWidget::createDeviceInfoWidget(Spotlight* spotlight) { @@ -148,15 +212,15 @@ QWidget* DevicesWidget::createInputMapperWidget(Settings* settings, Spotlight* / const auto intervalLayout = new QHBoxLayout(); const auto addBtn = new IconButton(Font::Icon::plus_5, imWidget); - addBtn->setToolTip(tr("Add a new input mapping entry.")); + addBtn->setToolTip(i18n("Add a new input mapping entry.")); const auto delBtn = new IconButton(Font::Icon::trash_can_1, imWidget); - delBtn->setToolTip(tr("Delete the selected input mapping entries (%1).", "%1=shortcut") - .arg(delShortcut->key().toString())); + delBtn->setToolTip(i18nc("%1=shortcut", "Delete the selected input mapping entries (%1).", + delShortcut->key().toString())); delBtn->setEnabled(false); - const auto intervalLbl = new QLabel(tr("Input Sequence Interval"), imWidget); + const auto intervalLbl = new QLabel(i18n("Input Sequence Interval"), imWidget); const auto intervalSb = new QSpinBox(this); - const auto intervalUnitLbl = new QLabel(tr("ms"), imWidget); + const auto intervalUnitLbl = new QLabel(i18n("ms"), imWidget); intervalSb->setMaximum(settings->inputSequenceIntervalRange().max); intervalSb->setMinimum(settings->inputSequenceIntervalRange().min); intervalSb->setValue(m_inputMapper ? m_inputMapper->keyEventInterval() @@ -234,7 +298,7 @@ QWidget* DevicesWidget::createInputMapperWidget(Settings* settings, Spotlight* / void DevicesWidget::createDeviceComboBox(Spotlight* spotlight) { m_devicesCombo = new QComboBox(this); - m_devicesCombo->setToolTip(tr("List of connected devices.")); + m_devicesCombo->setToolTip(i18n("List of connected devices.")); for (const auto& dev : spotlight->connectedDevices()) { const auto data = QVariant::fromValue(dev.id); @@ -287,7 +351,7 @@ QWidget* DevicesWidget::createDisconnectedStateWidget() { const auto stateWidget = new QWidget(this); const auto hbox = new QHBoxLayout(stateWidget); - const auto label = new QLabel(tr("No devices connected."), stateWidget); + const auto label = new QLabel(i18n("No devices connected."), stateWidget); label->setToolTip(label->text()); auto icon = style()->standardIcon(QStyle::SP_MessageBoxWarning); const auto iconLabel = new QLabel(stateWidget); @@ -299,111 +363,6 @@ QWidget* DevicesWidget::createDisconnectedStateWidget() return stateWidget; } -// ------------------------------------------------------------------------------------------------- -TimerTabWidget::TimerTabWidget(Settings* settings, QWidget* parent) - : QWidget(parent) - , m_settings(settings) - , m_multiTimerWidget(new MultiTimerWidget(this)) - , m_vibrationSettingsWidget(new VibrationSettingsWidget(this)) -{ - const auto layout = new QVBoxLayout(this); - - layout->addWidget(m_multiTimerWidget); - layout->addWidget(m_vibrationSettingsWidget); - - connect(m_multiTimerWidget, &MultiTimerWidget::timerValueChanged, this, - [this](int id, int secs) { - m_settings->setTimerSettings(m_deviceId, id, m_multiTimerWidget->timerEnabled(id), secs); - }); - - connect(m_multiTimerWidget, &MultiTimerWidget::timerEnabledChanged, this, - [this](int id, bool enabled) { - m_settings->setTimerSettings(m_deviceId, id, enabled, m_multiTimerWidget->timerValue(id)); - }); - - connect(m_vibrationSettingsWidget, &VibrationSettingsWidget::intensityChanged, this, - [this](uint8_t intensity) { - m_settings->setVibrationSettings(m_deviceId, m_vibrationSettingsWidget->length(), intensity); - }); - - connect(m_vibrationSettingsWidget, &VibrationSettingsWidget::lengthChanged, this, - [this](uint8_t len) { - m_settings->setVibrationSettings(m_deviceId, len, m_vibrationSettingsWidget->intensity()); - }); - - connect(m_multiTimerWidget, &MultiTimerWidget::timeout, - m_vibrationSettingsWidget, &VibrationSettingsWidget::sendVibrateCommand); -} - -// ------------------------------------------------------------------------------------------------- -void DevicesWidget::updateTimerTab(Spotlight* spotlight) -{ - // Helper method to return the first subconnection that supports vibrate. - auto getVibrateConnection = [](const std::shared_ptr& conn) { - if (conn) { - for (const auto& item : conn->subDevices()) { - if (item.second->hasFlags(DeviceFlag::Vibrate)) { return item.second; } - } - } - return std::shared_ptr{}; - }; - - const auto currentConn = spotlight->deviceConnection(currentDeviceId()); - const auto vibrateConn = getVibrateConnection(currentConn); - - if (m_timerTabContext) { m_timerTabContext->deleteLater(); } - - if (vibrateConn) - { - if (m_tabWidget->indexOf(m_timerTabWidget) < 0) { - m_tabWidget->insertTab(1, m_timerTabWidget, tr("Vibration Timer")); - } - m_timerTabWidget->setSubDeviceConnection(vibrateConn.get()); - } - else if (m_timerTabWidget) { - removeTab(m_tabWidget, m_timerTabWidget); - m_timerTabWidget->setSubDeviceConnection(nullptr); - } - - if (currentConn) { - m_timerTabContext = QPointer(new QObject(this)); - connect(&*currentConn, &DeviceConnection::subDeviceFlagsChanged, m_timerTabContext, - [currId=currentDeviceId(), spotlight, this](const DeviceId& id, const QString& /* path */) { - if (currId != id) { return; } - updateTimerTab(spotlight); - }); - } - -} - -// ------------------------------------------------------------------------------------------------- -void TimerTabWidget::loadSettings(const DeviceId& deviceId) -{ - m_multiTimerWidget->stopAllTimers(); - m_multiTimerWidget->blockSignals(true); - m_vibrationSettingsWidget->blockSignals(true); - - m_deviceId = deviceId; - - for (int i = 0; i < m_multiTimerWidget->timerCount(); ++i) { - const auto ts = m_settings->timerSettings(deviceId, i); - m_multiTimerWidget->setTimerEnabled(i, ts.first); - m_multiTimerWidget->setTimerValue(i, ts.second); - } - - const auto vs = m_settings->vibrationSettings(deviceId); - m_vibrationSettingsWidget->setLength(vs.first); - m_vibrationSettingsWidget->setIntensity(vs.second); - - m_vibrationSettingsWidget->blockSignals(false); - m_multiTimerWidget->blockSignals(false); -} - -// ------------------------------------------------------------------------------------------------- -void TimerTabWidget::setSubDeviceConnection(SubDeviceConnection* sdc) { - m_vibrationSettingsWidget->setSubDeviceConnection(sdc); -} - // ------------------------------------------------------------------------------------------------- DeviceInfoWidget::DeviceInfoWidget(QWidget* parent) : QWidget(parent) @@ -590,7 +549,7 @@ void DeviceInfoWidget::updateTextEdit() { // Insert list of sub devices cursor.insertBlock(); cursor.insertBlock(); - cursor.insertText(tr("Sub devices:"), underlineFormat); + cursor.insertText(i18n("Sub devices:"), underlineFormat); cursor.insertText(" ", normalFormat); cursor.insertBlock(); cursor.movePosition(QTextCursor::PreviousBlock); @@ -615,7 +574,7 @@ void DeviceInfoWidget::updateTextEdit() if (!m_batteryInfo.isEmpty()) { cursor.insertBlock(); - cursor.insertText(tr("Battery Info:"), underlineFormat); + cursor.insertText(i18n("Battery Info:"), underlineFormat); cursor.insertText(" ", normalFormat); cursor.insertText(m_batteryInfo); cursor.insertBlock(); @@ -624,7 +583,7 @@ void DeviceInfoWidget::updateTextEdit() if (!m_hidppInfo.presenterState.isEmpty()) { cursor.insertBlock(); - cursor.insertText(tr("HID++ Info:"), underlineFormat); + cursor.insertText(i18n("HID++ Info:"), underlineFormat); cursor.insertText(" ", normalFormat); cursor.insertBlock(); cursor.movePosition(QTextCursor::PreviousBlock); @@ -636,23 +595,23 @@ void DeviceInfoWidget::updateTextEdit() cursor.insertList(listFormat); if (!m_hidppInfo.receiverState.isEmpty()) { - cursor.insertText(tr("Receiver state:"), italicFormat); + cursor.insertText(i18n("Receiver state:"), italicFormat); cursor.insertText(" ", normalFormat); cursor.insertText(m_hidppInfo.receiverState); } cursor.insertBlock(); - cursor.insertText(tr("Presenter state:"), italicFormat); + cursor.insertText(i18n("Presenter state:"), italicFormat); cursor.insertText(" ", normalFormat); cursor.insertText(m_hidppInfo.presenterState); cursor.insertBlock(); - cursor.insertText(tr("Protocol version:"), italicFormat); + cursor.insertText(i18n("Protocol version:"), italicFormat); cursor.insertText(" ", normalFormat); cursor.insertText(m_hidppInfo.protocolVersion); cursor.insertBlock(); - cursor.insertText(tr("Supported features:"), italicFormat); + cursor.insertText(i18n("Supported features:"), italicFormat); cursor.insertText(" ", normalFormat); cursor.insertText(m_hidppInfo.hidppFlags.join(", ")); @@ -731,10 +690,15 @@ void DeviceInfoWidget::updateBatteryInfo(SubHidppConnection* hdc) const auto batteryInfo = hdc->batteryInfo(); if (batteryInfo.status == HIDPP::BatteryStatus::Discharging) { - m_batteryInfo = QString("%1% - %2% (%3)").arg( - QString::number(batteryInfo.currentLevel), - QString::number(batteryInfo.nextReportedLevel), - toString(batteryInfo.status)); + if (batteryInfo.currentLevel == batteryInfo.nextReportedLevel) { + m_batteryInfo = QString("%1% (%2)").arg( + QString::number(batteryInfo.currentLevel), toString(batteryInfo.status)); + } else { + m_batteryInfo = QString("%1% - %2% (%3)").arg( + QString::number(batteryInfo.currentLevel), + QString::number(batteryInfo.nextReportedLevel), + toString(batteryInfo.status)); + } } else { m_batteryInfo = toString(batteryInfo.status); } diff --git a/src/deviceswidget.h b/src/deviceswidget.h index cde9dc1d..1e76ad21 100644 --- a/src/deviceswidget.h +++ b/src/deviceswidget.h @@ -13,16 +13,14 @@ class DeviceConnection; class InputMapper; -class MultiTimerWidget; class QComboBox; +class QSpinBox; class QTabWidget; class QTextEdit; class Settings; class Spotlight; -class VibrationSettingsWidget; class SubDeviceConnection; class SubHidppConnection; -class TimerTabWidget; // ------------------------------------------------------------------------------------------------- class DevicesWidget : public QWidget @@ -41,38 +39,21 @@ class DevicesWidget : public QWidget void createDeviceComboBox(Spotlight* spotlight); QWidget* createDevicesWidget(Settings* settings, Spotlight* spotlight); QWidget* createInputMapperWidget(Settings* settings, Spotlight* spotlight); + QWidget* createTimerFeedbackWidget(Settings* settings); QWidget* createDeviceInfoWidget(Spotlight* spotlight); - TimerTabWidget* createTimerTabWidget(Settings* settings, Spotlight* spotlight); - void updateTimerTab(Spotlight* spotlight); + void updateTimerFeedbackTab(Spotlight* spotlight); + void loadTimerFeedbackSettings(Settings* settings, const DeviceId& deviceId); QComboBox* m_devicesCombo = nullptr; QTabWidget* m_tabWidget = nullptr; - TimerTabWidget* m_timerTabWidget = nullptr; - QPointer m_timerTabContext; + QWidget* m_timerFeedbackWidget = nullptr; + QSpinBox* m_timerFeedbackStrength = nullptr; QWidget* m_deviceDetailsTabWidget = nullptr; + QPointer m_timerFeedbackContext; QPointer m_inputMapper; }; -// ------------------------------------------------------------------------------------------------- -class TimerTabWidget : public QWidget -{ - Q_OBJECT - -public: - TimerTabWidget(Settings* settings, QWidget* parent = nullptr); - VibrationSettingsWidget* vibrationSettingsWidget(); - - void loadSettings(const DeviceId& deviceId); - void setSubDeviceConnection(SubDeviceConnection* sdc); - -private: - DeviceId m_deviceId; - Settings* const m_settings = nullptr; - MultiTimerWidget* m_multiTimerWidget = nullptr; - VibrationSettingsWidget* m_vibrationSettingsWidget = nullptr; -}; - // ------------------------------------------------------------------------------------------------- class DeviceInfoWidget : public QWidget { @@ -127,4 +108,4 @@ class DeviceInfoWidget : public QWidget QPointer m_connectionContext; QPointer m_connection; -}; \ No newline at end of file +}; diff --git a/src/hidpp.cc b/src/hidpp.cc index e23403f3..bce238ce 100644 --- a/src/hidpp.cc +++ b/src/hidpp.cc @@ -4,31 +4,24 @@ #include "hidpp.h" #include "enum-helper.h" -#include "logging.h" - +#include "projecteur_hid_debug.h" #include #include #include -#include -#include -#include -#include +#include -DECLARE_LOGGING_CATEGORY(hid) +#include +#include namespace { - // ----------------------------------------------------------------------------------------------- - #if (QT_VERSION < QT_VERSION_CHECK(6, 0, 0)) - const auto registered_ = qRegisterMetaTypeStreamOperators() - && qRegisterMetaTypeStreamOperators(); - #endif + const auto registeredMetaTypes_ = qRegisterMetaType() + && qRegisterMetaType(); // ----------------------------------------------------------------------------------------------- - constexpr char featureSetFilename[] = "DeviceFeatureSet.conf"; - constexpr char firmwareKey[] = "firmwareVersion"; - constexpr char featureTableKey[] = "featureTable"; + constexpr quint32 featureSetCacheFormatVersion = 1; + constexpr unsigned featureSetCacheSize = 1024 * 1024; // ----------------------------------------------------------------------------------------------- namespace Defaults { @@ -77,9 +70,56 @@ namespace { } // ----------------------------------------------------------------------------------------------- - QString settingsKey(const DeviceId& dId, const QString& key) { - return QString("Device_%1_%2/%3") - .arg(logging::hexId(dId.vendorId), logging::hexId(dId.productId), key); + QString featureSetCacheKey(const DeviceId& dId) { + return QString("Device_%1_%2") + .arg(formatHexId(dId.vendorId), formatHexId(dId.productId)); + } + + // ----------------------------------------------------------------------------------------------- + KSharedDataCache& featureSetCache() + { + static KSharedDataCache cache( + QStringLiteral("projecteur-hidpp-features"), featureSetCacheSize); + return cache; + } + + // ----------------------------------------------------------------------------------------------- + bool loadCachedFeatureSet(const DeviceId& dId, const HIDPP::FirmwareInfo& firmware, + HIDPP::FeatureSet::FeatureTable& featureTable) + { + QByteArray data; + if (!featureSetCache().find(featureSetCacheKey(dId), &data)) { + return false; + } + + QDataStream stream(data); + stream.setVersion(QDataStream::Qt_6_0); + + quint32 formatVersion = 0; + HIDPP::FirmwareInfo cachedFirmware; + HIDPP::FeatureSet::FeatureTable cachedFeatureTable; + stream >> formatVersion >> cachedFirmware >> cachedFeatureTable; + if (stream.status() != QDataStream::Ok + || formatVersion != featureSetCacheFormatVersion + || !(cachedFirmware == firmware)) { + return false; + } + + featureTable = std::move(cachedFeatureTable); + return true; + } + + // ----------------------------------------------------------------------------------------------- + void cacheFeatureSet(const DeviceId& dId, const HIDPP::FirmwareInfo& firmware, + const HIDPP::FeatureSet::FeatureTable& featureTable) + { + QByteArray data; + QDataStream stream(&data, QIODevice::WriteOnly); + stream.setVersion(QDataStream::Qt_6_0); + stream << featureSetCacheFormatVersion << firmware << featureTable; + if (stream.status() == QDataStream::Ok) { + featureSetCache().insert(featureSetCacheKey(dId), data); + } } } // end anonymous namespace @@ -370,14 +410,13 @@ void FeatureSet::getFeatureIndex(FeatureCode fc, std::function(to_integral(fc) >> 8); const auto fcMSB = static_cast(to_integral(fc) & 0x00ff); - Message featureIndexReqMsg(Message::Type::Long, DeviceIndex::WirelessDevice1, + Message featureIndexReqMsg(Message::Type::Long, m_connection->deviceIndex(), Message::Data{fcLSB, fcMSB}); m_connection->sendRequest(std::move(featureIndexReqMsg), [cb=std::move(cb), fc](MsgResult result, Message&& msg) { - logDebug(hid) << tr("getFeatureIndex(%1) => %2, %3") - .arg(to_integral(fc)).arg(toString(result)).arg(msg[4]); + qCDebug(PROJECTEUR_HID_LOG).noquote() << QStringLiteral("getFeatureIndex(%1) => %2, %3").arg(to_integral(fc)).arg(toString(result)).arg(msg[4]); if (cb) { cb(result, (result != MsgResult::Ok) ? 0 : msg[4]); } }); }); @@ -395,7 +434,7 @@ void FeatureSet::getFeatureCount(std::functiondeviceIndex(), featureIndex); m_connection->sendRequest(std::move(featureCountReqMsg), [featureIndex, cb=std::move(cb)](MsgResult result, Message&& msg) { @@ -416,13 +455,12 @@ void FeatureSet::getFirmwareCount(std::functiondeviceIndex(), featureIndex); m_connection->sendRequest(std::move(fwCountReqMsg), [featureIndex, cb=std::move(cb)](MsgResult result, Message&& msg) { - logDebug(hid) << tr("getFirmwareCount() => %1, featureIndex = %2, count = %3") - .arg(toString(result)).arg(featureIndex).arg(msg[4]); + qCDebug(PROJECTEUR_HID_LOG).noquote() << QStringLiteral("getFirmwareCount() => %1, featureIndex = %2, count = %3").arg(toString(result)).arg(featureIndex).arg(msg[4]); if (cb) { cb(result, featureIndex, (result != MsgResult::Ok) ? 0 : msg[4]); } }); })); @@ -438,7 +476,7 @@ void FeatureSet::getFirmwareInfo(uint8_t fwIndex, uint8_t entity, return; } - Message fwVerReqMessage(Message::Type::Long, DeviceIndex::WirelessDevice1, fwIndex, 1, + Message fwVerReqMessage(Message::Type::Long, m_connection->deviceIndex(), fwIndex, 1, Message::Data{entity}); m_connection->sendRequest(std::move(fwVerReqMessage), @@ -469,9 +507,7 @@ void FeatureSet::getMainFirmwareInfo(uint8_t fwIndex, uint8_t max, uint8_t curre getFirmwareInfo(fwIndex, current, makeSafeCallback( [this, current, max, fwIndex, cb=std::move(cb)](MsgResult res, FirmwareInfo&& fi) mutable { - logDebug(hid) << tr("getFirmwareInfo(%1, %2, %3) => %4, fi.type = %5, fi.ver = %6, fi.pref = %7") - .arg(fwIndex).arg(max).arg(current).arg(toString(res)) - .arg(to_integral(fi.firmwareType())).arg(fi.firmwareVersion()).arg(fi.firmwarePrefix()); + qCDebug(PROJECTEUR_HID_LOG).noquote() << QStringLiteral("getFirmwareInfo(%1, %2, %3) => %4, fi.type = %5, fi.ver = %6, fi.pref = %7").arg(fwIndex).arg(max).arg(current).arg(toString(res)).arg(to_integral(fi.firmwareType())).arg(fi.firmwareVersion()).arg(fi.firmwarePrefix()); if (res == MsgResult::Ok && fi.firmwareType() == FirmwareInfo::FirmwareType::MainApp) { @@ -504,45 +540,28 @@ void FeatureSet::initFromDevice(DeviceId dId, std::function cb) getMainFirmwareInfo(makeSafeCallback( [this, dId, cb=std::move(cb)](MsgResult res, FirmwareInfo&& fi) mutable { - logDebug(hid) << tr("getMainFirmwareInfo() => %1, fi.type = %2").arg(toString(res)) - .arg(to_integral(fi.firmwareType())); + qCDebug(PROJECTEUR_HID_LOG).noquote() << QStringLiteral("getMainFirmwareInfo() => %1, fi.type = %2").arg(toString(res)).arg(to_integral(fi.firmwareType())); if (fi.firmwareType() == FirmwareInfo::FirmwareType::MainApp) { m_mainFirmwareInfo = std::move(fi); } - // --- Try to load feature set from cache file - const auto cacheFile = QStandardPaths::locate( - QStandardPaths::StandardLocation::AppLocalDataLocation, featureSetFilename); - - if (!cacheFile.isEmpty() && res == MsgResult::Ok && m_mainFirmwareInfo.isValid()) + // --- Try to load the feature set from the KDE shared data cache. + if (res == MsgResult::Ok && m_mainFirmwareInfo.isValid() + && loadCachedFeatureSet(dId, m_mainFirmwareInfo, m_featureTable)) { - // load feature set and return - QSettings settings(cacheFile, QSettings::NativeFormat); - const auto fw = settings.value(settingsKey(dId, firmwareKey)); - if (fw.canConvert()) - { - auto cacheFirmwareInfo = fw.value(); - if (cacheFirmwareInfo == m_mainFirmwareInfo) - { - const auto table = settings.value(settingsKey(dId, featureTableKey)); - if (table.canConvert()) - { - m_featureTable = table.value(); - logDebug(hid) << tr("Loaded feature set with %1 entries from local cache").arg(m_featureTable.size()); - setState(State::Initialized); - if (cb) { cb(m_state); } - return; - } - } - } + qCDebug(PROJECTEUR_HID_LOG).noquote() + << QStringLiteral("Loaded feature set with %1 entries from local cache") + .arg(m_featureTable.size()); + setState(State::Initialized); + if (cb) { cb(m_state); } + return; } getFeatureCount(makeSafeCallback( [this, dId, cb=std::move(cb)](MsgResult res, uint8_t featureIndex, uint8_t count) mutable { - logDebug(hid) << tr("getFeatureCount() => %1, featureIndex = %2, count = %3") - .arg(toString(res)).arg(featureIndex).arg(count); + qCDebug(PROJECTEUR_HID_LOG).noquote() << QStringLiteral("getFeatureCount() => %1, featureIndex = %2, count = %3").arg(toString(res)).arg(featureIndex).arg(count); if (res != MsgResult::Ok) { @@ -562,16 +581,8 @@ void FeatureSet::initFromDevice(DeviceId dId, std::function cb) m_featureTable = std::move(ft); setState(State::Initialized); - // Store feature table in cache file - const auto dataPath = QStandardPaths::writableLocation( - QStandardPaths::StandardLocation::AppLocalDataLocation); - - if (!dataPath.isEmpty() && m_mainFirmwareInfo.isValid()) - { - const auto cacheFile = QDir(dataPath).filePath(featureSetFilename); - QSettings settings(cacheFile, QSettings::NativeFormat); - settings.setValue(settingsKey(dId, firmwareKey), QVariant::fromValue(m_mainFirmwareInfo)); - settings.setValue(settingsKey(dId, featureTableKey), QVariant::fromValue(m_featureTable)); + if (m_mainFirmwareInfo.isValid()) { + cacheFeatureSet(dId, m_mainFirmwareInfo, m_featureTable); } } @@ -604,7 +615,7 @@ void FeatureSet::getFeatureIds(uint8_t featureSetIndex, uint8_t count, for (uint8_t featureIndex = 1; featureIndex <= count; ++featureIndex) { batch.emplace(HidppConnectionInterface::RequestBatchItem { - Message(Message::Type::Long, DeviceIndex::WirelessDevice1, featureSetIndex, 1, + Message(Message::Type::Long, m_connection->deviceIndex(), featureSetIndex, 1, Message::Data{featureIndex}), [featureTable, featureIndex](MsgResult res, Message&& msg) { @@ -732,6 +743,8 @@ const char* toString(HIDPP::FeatureCode fc) ENUM_CASE_STRINGIFY(FeatureCode::Reset); ENUM_CASE_STRINGIFY(FeatureCode::DFUControlSigned); ENUM_CASE_STRINGIFY(FeatureCode::BatteryStatus); + ENUM_CASE_STRINGIFY(FeatureCode::UnifiedBattery); + ENUM_CASE_STRINGIFY(FeatureCode::Haptic); ENUM_CASE_STRINGIFY(FeatureCode::PresenterControl); ENUM_CASE_STRINGIFY(FeatureCode::Sensor3D); ENUM_CASE_STRINGIFY(FeatureCode::ReprogramControlsV4); diff --git a/src/hidpp.h b/src/hidpp.h index 9cd861ca..76141a88 100644 --- a/src/hidpp.h +++ b/src/hidpp.h @@ -12,6 +12,7 @@ #include #include +#include #include // Hidpp specific functionality @@ -43,6 +44,8 @@ namespace HIDPP { Reset = 0x0020, DFUControlSigned = 0x00c2, BatteryStatus = 0x1000, + UnifiedBattery = 0x1004, + Haptic = 0x19b0, PresenterControl = 0x1a00, Sensor3D = 0x1a01, ReprogramControlsV4 = 0x1b04, @@ -246,6 +249,7 @@ class HidppConnectionInterface using RequestResultCallback = std::function; virtual BusType busType() const = 0; + virtual uint8_t deviceIndex() const = 0; // --- synchronous versions virtual ssize_t sendData(std::vector msg) = 0; @@ -384,11 +388,11 @@ const char* toString(HIDPP::BatteryStatus bs); const char* toString(HIDPP::Notification n); // ------------------------------------------------------------------------------------------------- -Q_DECLARE_METATYPE(HIDPP::FeatureSet::FeatureTable); QDataStream& operator<<(QDataStream& s, const HIDPP::FeatureSet::FeatureTable& ft); QDataStream& operator>>(QDataStream& s, HIDPP::FeatureSet::FeatureTable& ft); +Q_DECLARE_METATYPE(HIDPP::FeatureSet::FeatureTable); // ------------------------------------------------------------------------------------------------- -Q_DECLARE_METATYPE(HIDPP::FirmwareInfo); QDataStream& operator<<(QDataStream& s, const HIDPP::FirmwareInfo& fi); QDataStream& operator>>(QDataStream& s, HIDPP::FirmwareInfo& fi); +Q_DECLARE_METATYPE(HIDPP::FirmwareInfo); diff --git a/src/iconwidgets.cc b/src/iconwidgets.cc index d789bd94..57ba6786 100644 --- a/src/iconwidgets.cc +++ b/src/iconwidgets.cc @@ -22,7 +22,7 @@ IconButton::IconButton(Font::Icon symbol, QWidget* parent) iconFont.setPointSizeF(font().pointSizeF()); setFont(iconFont); - setText(QChar(symbol)); + setText(QChar(static_cast(symbol))); auto p = palette(); p.setColor(QPalette::ColorGroup::Normal, QPalette::ButtonText, @@ -33,7 +33,7 @@ IconButton::IconButton(Font::Icon symbol, QWidget* parent) // ------------------------------------------------------------------------------------------------- IconLabel::IconLabel(Font::Icon symbol, QWidget* parent) - : QLabel(QChar(symbol), parent) + : QLabel(QChar(static_cast(symbol)), parent) { QFont iconFont("projecteur-icons"); iconFont.setPixelSize(defaultIconLabelSize); diff --git a/src/inputmapconfig.cc b/src/inputmapconfig.cc index f23a494a..537bc694 100644 --- a/src/inputmapconfig.cc +++ b/src/inputmapconfig.cc @@ -5,7 +5,8 @@ #include "actiondelegate.h" #include "inputseqedit.h" -#include "logging.h" + +#include #include #include @@ -60,9 +61,9 @@ QVariant InputMapConfigModel::headerData(int section, Qt::Orientation orientatio { switch(section) { - case InputSeqCol: return tr("Input Sequence"); + case InputSeqCol: return i18n("Input Sequence"); case ActionTypeCol: return "Type"; - case ActionCol: return tr("Mapped Action"); + case ActionCol: return i18n("Mapped Action"); default: return "Invalid"; } } @@ -412,4 +413,3 @@ void InputMapConfigView::keyPressEvent(QKeyEvent* e) QTableView::keyPressEvent(e); } - diff --git a/src/inputseqedit.cc b/src/inputseqedit.cc index 0513c05c..447030dc 100644 --- a/src/inputseqedit.cc +++ b/src/inputseqedit.cc @@ -6,7 +6,8 @@ #include "device-key-lookup.h" #include "deviceinput.h" #include "inputmapconfig.h" -#include "logging.h" + +#include #include #include @@ -183,20 +184,11 @@ QSize InputSeqEdit::sizeHint() const constexpr int verticalMargin = 3; constexpr int horizontalMargin = 3; const int h = fm.height() + 2 * verticalMargin; - #if (QT_VERSION >= QT_VERSION_CHECK(5, 11, 0)) - const int w = fm.horizontalAdvance(QLatin1Char('x')) * 17 + 2 * horizontalMargin; - #else - const int w = fm.width(QLatin1Char('x')) * 17 + 2 * horizontalMargin; - #endif + const int w = fm.horizontalAdvance(QLatin1Char('x')) * 17 + 2 * horizontalMargin; const QStyleOptionFrame option = styleOption(); - #if (QT_VERSION < QT_VERSION_CHECK(6, 0, 0)) - return (style()->sizeFromContents(QStyle::CT_LineEdit, &option, QSize(w, h). - expandedTo(QApplication::globalStrut()), this)); - #else return style()->sizeFromContents(QStyle::CT_LineEdit, &option, QSize(w, h), this); - #endif } // ------------------------------------------------------------------------------------------------- @@ -217,7 +209,7 @@ void InputSeqEdit::paintEvent(QPaintEvent* /* paintEvent */) const auto spacingX = QStaticText(" ").size().width(); xPos += drawRecordingSymbol(xPos, p, option) + spacingX; if (m_recordedSequence.empty()) { - drawPlaceHolderText(xPos, p, option, tr("Press device button(s)...")); + drawPlaceHolderText(xPos, p, option, i18n("Press device button(s)...")); } else { drawKeyEventSequence(xPos, p, option, m_recordedSequence, m_deviceId, false); } @@ -389,7 +381,7 @@ int InputSeqEdit::drawEmptyIndicator(int startX, QPainter& p, const QStyleOption p.setPen(option.palette.color(QPalette::Disabled, QPalette::Text)); } - static const QStaticText textNone(InputSeqEdit::tr("None")); + static const QStaticText textNone(i18n("None")); const auto top = static_cast((option.rect.height() - textNone.size().height()) / 2); p.drawStaticText(startX + option.rect.left(), option.rect.top() + top, textNone); p.restore(); diff --git a/src/kwinscreencast.cc b/src/kwinscreencast.cc new file mode 100644 index 00000000..dc45cbb1 --- /dev/null +++ b/src/kwinscreencast.cc @@ -0,0 +1,88 @@ +// This file is part of Projecteur - https://github.com/jahnf/projecteur +// - See LICENSE.md and README.md + +#include "kwinscreencast.h" + +#include "projecteur_desktop_debug.h" + +#include + +KWinScreencastStream::KWinScreencastStream( + struct ::zkde_screencast_stream_unstable_v1* stream, QObject* parent) + : QObject(parent) + , QtWayland::zkde_screencast_stream_unstable_v1(stream) +{ +} + +KWinScreencastStream::~KWinScreencastStream() +{ + if (isInitialized()) { + close(); + } +} + +void KWinScreencastStream::zkde_screencast_stream_unstable_v1_closed() +{ + if (isInitialized()) { + close(); + } + emit closed(); +} + +void KWinScreencastStream::zkde_screencast_stream_unstable_v1_created(uint32_t node) +{ + if (m_nodeId == node) { + return; + } + m_nodeId = node; + emit nodeIdChanged(); +} + +void KWinScreencastStream::zkde_screencast_stream_unstable_v1_failed( + const QString& error) +{ + m_error = error; + emit errorChanged(); + qCWarning(PROJECTEUR_DESKTOP_LOG).noquote() + << QStringLiteral("KWin screencast failed: %1").arg(error); +} + +void KWinScreencastStream::zkde_screencast_stream_unstable_v1_serial( + uint32_t objectSerialHi, uint32_t objectSerialLow) +{ + const quint64 serial = (quint64(objectSerialHi) << 32) | objectSerialLow; + if (m_objectSerial == serial) { + return; + } + m_objectSerial = serial; + emit objectSerialChanged(); +} + +KWinScreencast::KWinScreencast(QObject* parent) + : QWaylandClientExtensionTemplate(6) +{ + setParent(parent); +} + +KWinScreencast::~KWinScreencast() +{ + if (isInitialized()) { + destroy(); + } +} + +KWinScreencastStream* KWinScreencast::streamScreen(QScreen* screen, QObject* parent) +{ + if (!isActive() || !screen) { + return nullptr; + } + + const QRect geometry = screen->geometry(); + auto* const stream = QtWayland::zkde_screencast_unstable_v1::stream_region( + geometry.x(), geometry.y(), geometry.width(), geometry.height(), + 0, pointer_hidden); + if (!stream) { + return nullptr; + } + return new KWinScreencastStream(stream, parent); +} diff --git a/src/kwinscreencast.h b/src/kwinscreencast.h new file mode 100644 index 00000000..d4e86229 --- /dev/null +++ b/src/kwinscreencast.h @@ -0,0 +1,59 @@ +// This file is part of Projecteur - https://github.com/jahnf/projecteur +// - See LICENSE.md and README.md +#pragma once + +#include "qwayland-zkde-screencast-unstable-v1.h" + +#include + +class QScreen; + +class KWinScreencastStream final + : public QObject + , public QtWayland::zkde_screencast_stream_unstable_v1 +{ + Q_OBJECT + Q_PROPERTY(quint64 objectSerial READ objectSerial NOTIFY objectSerialChanged) + Q_PROPERTY(uint nodeId READ nodeId NOTIFY nodeIdChanged) + Q_PROPERTY(QString error READ error NOTIFY errorChanged) + +public: + explicit KWinScreencastStream( + struct ::zkde_screencast_stream_unstable_v1* stream, + QObject* parent = nullptr); + ~KWinScreencastStream() override; + + quint64 objectSerial() const { return m_objectSerial; } + uint nodeId() const { return m_nodeId; } + QString error() const { return m_error; } + +signals: + void objectSerialChanged(); + void nodeIdChanged(); + void errorChanged(); + void closed(); + +private: + void zkde_screencast_stream_unstable_v1_closed() override; + void zkde_screencast_stream_unstable_v1_created(uint32_t node) override; + void zkde_screencast_stream_unstable_v1_failed(const QString& error) override; + void zkde_screencast_stream_unstable_v1_serial( + uint32_t objectSerialHi, uint32_t objectSerialLow) override; + + quint64 m_objectSerial = 0; + uint m_nodeId = 0; + QString m_error; +}; + +class KWinScreencast final + : public QWaylandClientExtensionTemplate + , public QtWayland::zkde_screencast_unstable_v1 +{ + Q_OBJECT + +public: + explicit KWinScreencast(QObject* parent = nullptr); + ~KWinScreencast() override; + + KWinScreencastStream* streamScreen(QScreen* screen, QObject* parent = nullptr); +}; diff --git a/src/linuxdesktop.cc b/src/linuxdesktop.cc index 72428b5c..d63c2a5f 100644 --- a/src/linuxdesktop.cc +++ b/src/linuxdesktop.cc @@ -3,84 +3,107 @@ #include "linuxdesktop.h" -#include "logging.h" +#include "kwinscreencast.h" +#include "projecteur_desktop_debug.h" -#include -#if (QT_VERSION < QT_VERSION_CHECK(6, 0, 0)) - #include -#endif -#include -#include -#include -#include +#include -#if HAS_Qt_DBus #include #include -#endif +#include +#include +#include +#include +#include +#include -LOGGING_CATEGORY(desktop, "desktop") +#include +#include +#include namespace { -#if HAS_Qt_DBus + constexpr auto kwinScreenshotService = "org.kde.KWin.ScreenShot2"; + constexpr auto kwinScreenshotPath = "/org/kde/KWin/ScreenShot2"; + constexpr auto kwinScreenshotInterface = "org.kde.KWin.ScreenShot2"; + constexpr auto kwinService = "org.kde.KWin"; + constexpr auto kwinEffectsPath = "/Effects"; + constexpr auto kwinEffectsInterface = "org.kde.kwin.Effects"; + constexpr auto shakeCursorEffect = "shakecursor"; + // ----------------------------------------------------------------------------------------------- - QPixmap grabScreenDBusGnome() + QPixmap grabScreenKWin(QScreen* screen) { - const auto filepath = QDir::temp().absoluteFilePath("000_projecteur_zoom_screenshot.png"); - QDBusInterface interface(QStringLiteral("org.gnome.Shell"), - QStringLiteral("/org/gnome/Shell/Screenshot"), - QStringLiteral("org.gnome.Shell.Screenshot")); - QDBusReply reply = interface.call(QStringLiteral("Screenshot"), false, false, filepath); + int pipeDescriptors[2] = {-1, -1}; + if (::pipe2(pipeDescriptors, O_CLOEXEC) != 0) { + qCCritical(PROJECTEUR_DESKTOP_LOG).noquote() << QStringLiteral("Could not create a pipe for the KWin screenshot."); + return {}; + } - if (reply.value()) + QFile readPipe; + if (!readPipe.open(pipeDescriptors[0], QIODevice::ReadOnly, QFileDevice::AutoCloseHandle)) { + ::close(pipeDescriptors[0]); + ::close(pipeDescriptors[1]); + qCCritical(PROJECTEUR_DESKTOP_LOG).noquote() << QStringLiteral("Could not open the KWin screenshot pipe."); + return {}; + } + + QDBusReply reply; { - QPixmap pm(filepath); - QFile::remove(filepath); - return pm; + QDBusUnixFileDescriptor writePipe; + writePipe.giveFileDescriptor(pipeDescriptors[1]); + QDBusInterface interface(kwinScreenshotService, kwinScreenshotPath, + kwinScreenshotInterface); + + QVariantMap options; + options.insert(QStringLiteral("hide-caller-windows"), true); + options.insert(QStringLiteral("native-resolution"), true); + + reply = interface.call(QStringLiteral("CaptureScreen"), screen->name(), options, + QVariant::fromValue(writePipe)); } - logError(desktop) << LinuxDesktop::tr("Screenshot via GNOME DBus interface failed."); - return QPixmap(); - } - // ----------------------------------------------------------------------------------------------- - QPixmap grabScreenDBusKde() - { - QDBusInterface interface(QStringLiteral("org.kde.KWin"), - QStringLiteral("/Screenshot"), - QStringLiteral("org.kde.kwin.Screenshot")); - QDBusReply reply = interface.call(QStringLiteral("screenshotFullscreen")); - QPixmap pm(reply.value()); - if (!pm.isNull()) { - QFile::remove(reply.value()); - } else { - logError(desktop) << LinuxDesktop::tr("Screenshot via KDE DBus interface failed."); + if (!reply.isValid()) { + auto message = i18n("Screenshot via KWin ScreenShot2 failed: %1", + reply.error().message()); + if (reply.error().name() == QStringLiteral("org.kde.KWin.ScreenShot2.Error.NoAuthorized")) { + message += i18n( + " Install Projecteur so KWin can associate the executable with its desktop metadata."); + } + qCCritical(PROJECTEUR_DESKTOP_LOG).noquote() << message; + return {}; } - return pm; - } -#endif // HAS_Qt_DBus - // ----------------------------------------------------------------------------------------------- - QPixmap grabScreenVirtualDesktop(QScreen* screen) - { - QRect g; - for (const auto s : QGuiApplication::screens()) { - g = g.united(s->geometry()); + const QVariantMap attributes = reply.value(); + const quint32 width = attributes.value(QStringLiteral("width")).toUInt(); + const quint32 height = attributes.value(QStringLiteral("height")).toUInt(); + const quint32 stride = attributes.value(QStringLiteral("stride")).toUInt(); + const quint32 formatValue = attributes.value(QStringLiteral("format")).toUInt(); + const qreal scale = attributes.value(QStringLiteral("scale"), 1.0).toDouble(); + + const quint64 expectedBytes = quint64(stride) * height; + if (width == 0 || height == 0 || stride == 0 + || expectedBytes > quint64(std::numeric_limits::max())) { + qCCritical(PROJECTEUR_DESKTOP_LOG).noquote() << QStringLiteral("KWin returned invalid screenshot dimensions."); + return {}; } - #if (QT_VERSION < QT_VERSION_CHECK(6, 0, 0)) - QPixmap pm(QApplication::primaryScreen()->grabWindow( - QApplication::desktop()->winId(), g.x(), g.y(), g.width(), g.height())); - #else - QPixmap pm(QApplication::primaryScreen()->grabWindow(0, g.x(), g.y(), g.width(), g.height())); - #endif + const QByteArray pixels = readPipe.readAll(); + if (quint64(pixels.size()) < expectedBytes) { + qCCritical(PROJECTEUR_DESKTOP_LOG).noquote() << QStringLiteral("KWin returned an incomplete screenshot."); + return {}; + } - if (!pm.isNull()) - { - pm.setDevicePixelRatio(screen->devicePixelRatio()); - return pm.copy(screen->geometry()); + const auto format = static_cast(formatValue); + const QImage image(reinterpret_cast(pixels.constData()), + int(width), int(height), int(stride), format); + if (image.isNull()) { + qCCritical(PROJECTEUR_DESKTOP_LOG).noquote() << QStringLiteral("KWin returned an unsupported screenshot format."); + return {}; } - return pm; + QPixmap pixmap = QPixmap::fromImage(image.copy()); + pixmap.setDevicePixelRatio(scale > 0 ? scale : 1.0); + return pixmap; } } // end anonymous namespace @@ -88,71 +111,94 @@ LinuxDesktop::LinuxDesktop(QObject* parent) : QObject(parent) { const auto env = QProcessEnvironment::systemEnvironment(); - { // check for Kde and Gnome - const auto kdeFullSession = env.value(QStringLiteral("KDE_FULL_SESSION")); - const auto gnomeSessionId = env.value(QStringLiteral("GNOME_DESKTOP_SESSION_ID")); - const auto desktopSession = env.value(QStringLiteral("DESKTOP_SESSION")); - const auto xdgCurrentDesktop = env.value(QStringLiteral("XDG_CURRENT_DESKTOP")); - if (gnomeSessionId.size() || xdgCurrentDesktop.contains("Gnome", Qt::CaseInsensitive)) { - m_type = LinuxDesktop::Type::Gnome; - } - else if (kdeFullSession.size() || desktopSession == "kde-plasma") { - m_type = LinuxDesktop::Type::KDE; - } + const auto kdeFullSession = env.value(QStringLiteral("KDE_FULL_SESSION")); + const auto desktopSession = env.value(QStringLiteral("DESKTOP_SESSION")); + const auto xdgCurrentDesktop = env.value(QStringLiteral("XDG_CURRENT_DESKTOP")); + + if (!kdeFullSession.isEmpty() + || desktopSession.contains(QStringLiteral("plasma"), Qt::CaseInsensitive) + || xdgCurrentDesktop.contains(QStringLiteral("KDE"), Qt::CaseInsensitive)) { + m_type = LinuxDesktop::Type::KDE; } - { // check for wayland session - const auto waylandDisplay = env.value(QStringLiteral("WAYLAND_DISPLAY")); - const auto xdgSessionType = env.value(QStringLiteral("XDG_SESSION_TYPE")); - m_wayland = (xdgSessionType == "wayland") - || waylandDisplay.contains("wayland", Qt::CaseInsensitive); + m_wayland = QGuiApplication::platformName().startsWith(QStringLiteral("wayland"), + Qt::CaseInsensitive); + if (m_wayland && m_type == LinuxDesktop::Type::KDE) { + m_screencast = new KWinScreencast(this); } } +LinuxDesktop::~LinuxDesktop() +{ + setShakeCursorEffectSuppressed(false); +} + QPixmap LinuxDesktop::grabScreen(QScreen* screen) const { - if (screen == nullptr) { - return QPixmap(); + if (!screen) { + return {}; } - - if (isWayland()) { - return grabScreenWayland(screen); + if (!isWayland()) { + qCWarning(PROJECTEUR_DESKTOP_LOG).noquote() << QStringLiteral("Screen capture is only supported on Wayland."); + return {}; } - - #if (QT_VERSION >= QT_VERSION_CHECK(5, 11, 0)) - const bool isVirtualDesktop = QApplication::primaryScreen()->virtualSiblings().size() > 1; - #else - const bool isVirtualDesktop = QApplication::desktop()->isVirtualDesktop(); - #endif - - if (isVirtualDesktop) { - return grabScreenVirtualDesktop(screen); + if (type() != LinuxDesktop::Type::KDE) { + qCWarning(PROJECTEUR_DESKTOP_LOG).noquote() << QStringLiteral("Screen capture is only supported on KDE Plasma."); + return {}; } + return grabScreenKWin(screen); +} - // everything else.. usually X11 - return screen->grabWindow(0); +QObject* LinuxDesktop::streamScreen(QScreen* screen, QObject* parent) +{ + return m_screencast ? m_screencast->streamScreen(screen, parent) : nullptr; } -QPixmap LinuxDesktop::grabScreenWayland(QScreen* screen) const +void LinuxDesktop::setShakeCursorEffectSuppressed(bool suppressed) { -#if HAS_Qt_DBus - QPixmap pm; - switch (type()) + if (!isWayland() || type() != LinuxDesktop::Type::KDE + || suppressed == m_shakeCursorEffectSuppressed) { + return; + } + + QDBusInterface interface(kwinService, kwinEffectsPath, kwinEffectsInterface); + if (!interface.isValid()) { + qCWarning(PROJECTEUR_DESKTOP_LOG).noquote() << QStringLiteral("Could not access KWin's desktop effects interface."); + return; + } + + if (suppressed) { - case LinuxDesktop::Type::Gnome: - pm = grabScreenDBusGnome(); - break; - case LinuxDesktop::Type::KDE: - pm = grabScreenDBusKde(); - break; - default: - logWarning(desktop) << tr("Currently zoom on Wayland is only supported via DBus on KDE and GNOME."); + const QDBusReply loadedReply = + interface.call(QStringLiteral("isEffectLoaded"), QString::fromLatin1(shakeCursorEffect)); + if (!loadedReply.isValid()) { + qCWarning(PROJECTEUR_DESKTOP_LOG).noquote() << QStringLiteral("Could not query KWin's Shake Cursor effect: %1").arg(loadedReply.error().message()); + return; + } + if (!loadedReply.value()) { + return; + } + + const QDBusReply unloadReply = + interface.call(QStringLiteral("unloadEffect"), QString::fromLatin1(shakeCursorEffect)); + if (!unloadReply.isValid()) { + qCWarning(PROJECTEUR_DESKTOP_LOG).noquote() << QStringLiteral("Could not suppress KWin's Shake Cursor effect: %1").arg(unloadReply.error().message()); + return; + } + + m_shakeCursorEffectSuppressed = true; + return; } - return pm.isNull() ? pm : pm.copy(screen->geometry()); -#else - Q_UNUSED(screen); - logWarning(desktop) << tr("Projecteur was compiled without Qt DBus. Currently zoom on Wayland is " - "only supported via DBus on KDE and GNOME."); - return QPixmap(); -#endif + + const QDBusReply loadReply = + interface.call(QStringLiteral("loadEffect"), QString::fromLatin1(shakeCursorEffect)); + if (!loadReply.isValid() || !loadReply.value()) { + const auto error = loadReply.isValid() + ? i18n("KWin refused to load the effect.") + : loadReply.error().message(); + qCWarning(PROJECTEUR_DESKTOP_LOG).noquote() << QStringLiteral("Could not restore KWin's Shake Cursor effect: %1").arg(error); + return; + } + + m_shakeCursorEffectSuppressed = false; } diff --git a/src/linuxdesktop.h b/src/linuxdesktop.h index 1c9048a1..19144ff2 100644 --- a/src/linuxdesktop.h +++ b/src/linuxdesktop.h @@ -5,6 +5,7 @@ #include #include +class KWinScreencast; class QScreen; class LinuxDesktop : public QObject @@ -12,18 +13,21 @@ class LinuxDesktop : public QObject Q_OBJECT public: - enum class Type : uint8_t { KDE, Gnome, Other }; + enum class Type : uint8_t { KDE, Other }; explicit LinuxDesktop(QObject* parent = nullptr); + ~LinuxDesktop() override; bool isWayland() const { return m_wayland; }; Type type() const { return m_type; }; QPixmap grabScreen(QScreen* screen) const; + QObject* streamScreen(QScreen* screen, QObject* parent = nullptr); + void setShakeCursorEffectSuppressed(bool suppressed); private: bool m_wayland = false; Type m_type = Type::Other; - - QPixmap grabScreenWayland(QScreen* screen) const; -}; \ No newline at end of file + bool m_shakeCursorEffectSuppressed = false; + KWinScreencast* m_screencast = nullptr; +}; diff --git a/src/logging.cc b/src/logging.cc deleted file mode 100644 index 9a77b08e..00000000 --- a/src/logging.cc +++ /dev/null @@ -1,208 +0,0 @@ -// This file is part of Projecteur - https://github.com/jahnf/projecteur -// - See LICENSE.md and README.md - -#include "logging.h" - -#include -#include -#include -#include -#include -#include - -#include - -namespace { - // ----------------------------------------------------------------------------------------------- - void projecteurLogHandler(QtMsgType type, const QMessageLogContext &context, const QString &msgQString); - void categoryFilterInfo(QLoggingCategory *category); - - // Install our custom message handler, store previous message handler - const QtMessageHandler defaultMessageHandler = qInstallMessageHandler(projecteurLogHandler); - const QLoggingCategory::CategoryFilter defaultCategoryFilter = QLoggingCategory::installFilter(categoryFilterInfo); - QLoggingCategory::CategoryFilter currentCategoryFilter = categoryFilterInfo; - - constexpr char categoryPrefix[] = "projecteur."; - inline bool isAppCategory(QLoggingCategory* category) { - return (qstrncmp(categoryPrefix, category->categoryName(), sizeof(categoryPrefix)-1) == 0); - } - - void categoryFilterDebug(QLoggingCategory *category) - { - if (isAppCategory(category)) - { - category->setEnabled(QtDebugMsg, true); - category->setEnabled(QtInfoMsg, true); - category->setEnabled(QtWarningMsg, true); - category->setEnabled(QtCriticalMsg, true); - } else { - defaultCategoryFilter(category); - } - } - - void categoryFilterInfo(QLoggingCategory *category) - { - if (isAppCategory(category)) { - category->setEnabled(QtDebugMsg, false); - category->setEnabled(QtInfoMsg, true); - category->setEnabled(QtWarningMsg, true); - category->setEnabled(QtCriticalMsg, true); - } else { - defaultCategoryFilter(category); - } - } - - void categoryFilterWarning(QLoggingCategory *category) - { - if (isAppCategory(category)) { - category->setEnabled(QtDebugMsg, false); - category->setEnabled(QtInfoMsg, false); - category->setEnabled(QtWarningMsg, true); - category->setEnabled(QtCriticalMsg, true); - } else { - defaultCategoryFilter(category); - } - } - - void categoryFilterError(QLoggingCategory *category) - { - if (isAppCategory(category)) - { - category->setEnabled(QtDebugMsg, false); - category->setEnabled(QtInfoMsg, false); - category->setEnabled(QtWarningMsg, false); - category->setEnabled(QtCriticalMsg, true); - } else { - defaultCategoryFilter(category); - } - } - - // ----------------------------------------------------------------------------------------------- - QPointer logPlainTextEdit; - QMetaMethod logAppendMetaMethod; - QList logPlainTextCache; // log messages are stored here until a text edit is registered - constexpr int logPlainTextCacheMax = 1000; - - // ----------------------------------------------------------------------------------------------- - void logToTextEdit(const QString& logMsg) - { - if (logPlainTextEdit) { - logAppendMetaMethod.invoke(logPlainTextEdit, Qt::QueuedConnection, Q_ARG(QString, logMsg)); - } else if (logPlainTextCache.size() < logPlainTextCacheMax) { - logPlainTextCache.push_back(logMsg); - } - } - - // ----------------------------------------------------------------------------------------------- - inline const char* typeToShortString(QtMsgType type) { - switch (type) { - case QtDebugMsg: return "dbg"; - case QtInfoMsg: return "inf"; - case QtWarningMsg: return "wrn"; - case QtCriticalMsg: return "err"; - case QtFatalMsg: return "fat"; - } - return ""; - } - - // ----------------------------------------------------------------------------------------------- - // Currently all logging is done from within the Qt Gui thread - // - if that changes and multiple threads will log, this needs a serious overhaul - NOT thread safe - void projecteurLogHandler(QtMsgType type, const QMessageLogContext &context, const QString &msgQString) - { - const char *category = context.category ? context.category : ""; - - #if (QT_VERSION >= QT_VERSION_CHECK(5, 8, 0)) - constexpr auto dateFormat = Qt::ISODateWithMs; - #else - constexpr auto dateFormat = Qt::ISODate; - #endif - - const auto logMsg = QString("[%1][%2][%3] %4").arg(QDateTime::currentDateTime().toString(dateFormat), - typeToShortString(type), category, msgQString); - - if (type == QtDebugMsg || type == QtInfoMsg) { - std::cout << qUtf8Printable(logMsg) << std::endl; - } else { - std::cerr << qUtf8Printable(logMsg) << std::endl; - } - - logToTextEdit(logMsg); - } -} // end anonymous namespace - -namespace logging { - void registerTextEdit(QPlainTextEdit* textEdit) - { - logPlainTextEdit = textEdit; - if (!logPlainTextEdit) { return; } - - const auto index = logPlainTextEdit->metaObject()->indexOfMethod("appendPlainText(QString)"); - logAppendMetaMethod = logPlainTextEdit->metaObject()->method(index); - - for (const auto& logMsg : logPlainTextCache) { - logAppendMetaMethod.invoke(logPlainTextEdit, Qt::QueuedConnection, Q_ARG(QString, logMsg)); - } - - logPlainTextCache.clear(); - } - - const char* levelToString(level lvl) - { - switch (lvl) { - case level::debug: return "debug"; - case level::info: return "info"; - case level::warning: return "warning"; - case level::error: return "error"; - case level::custom: return "default/custom"; - case level::unknown: return "unknown"; - } - return ""; - } - - level levelFromName(const QString& name) - { - const auto lvlName = name.toLower(); - if (lvlName == "dbg" || lvlName == "debug") { return level::debug; } - if (lvlName == "inf" || lvlName == "info") { return level::info; } - if (lvlName == "wrn" || lvlName == "warning") { return level::warning; } - if (lvlName == "err" || lvlName == "error") { return level::error; } - return level::unknown; - } - - level currentLevel() - { - if (currentCategoryFilter == defaultCategoryFilter) { return level::custom; } - if (currentCategoryFilter == categoryFilterDebug) { return level::debug; } - if (currentCategoryFilter == categoryFilterInfo) { return level::info; } - if (currentCategoryFilter == categoryFilterWarning) { return level::warning; } - if (currentCategoryFilter == categoryFilterError) { return level::error; } - return level::unknown; - } - - void setCurrentLevel(level lvl) - { - QLoggingCategory::CategoryFilter newFilter = currentCategoryFilter; - - if (lvl == level::debug) { - newFilter = categoryFilterDebug; - } else if (lvl == level::info) { - newFilter = categoryFilterInfo; - } else if (lvl == level::warning) { - newFilter = categoryFilterWarning; - } else if (lvl == level::error) { - newFilter = categoryFilterError; - } else if (lvl == level::custom) { - newFilter = defaultCategoryFilter; - } - - if (newFilter != currentCategoryFilter) { - QLoggingCategory::installFilter(newFilter); - currentCategoryFilter = newFilter; - } - } - - QString hexId(uint16_t id) { - return QString("%1").arg(id, 4, 16, QChar('0')); - } -} // end namespace logging diff --git a/src/logging.h b/src/logging.h deleted file mode 100644 index b849c734..00000000 --- a/src/logging.h +++ /dev/null @@ -1,77 +0,0 @@ -// This file is part of Projecteur - https://github.com/jahnf/Projecteur -// - See LICENSE.md and README.md -#pragma once - -#include - -#define _NARG__(...) _NARG_I_(__VA_ARGS__,_RSEQ_N()) -#define _NARG_I_(...) _ARG_N(__VA_ARGS__) -#define _ARG_N( \ - _1, _2, _3, _4, _5, _6, _7, _8, _9,_10, \ - _11,_12,_13,_14,_15,_16,_17,_18,_19,_20, \ - _21,_22,_23,_24,_25,_26,_27,_28,_29,_30, \ - _31,_32,_33,_34,_35,_36,_37,_38,_39,_40, \ - _41,_42,_43,_44,_45,_46,_47,_48,_49,_50, \ - _51,_52,_53,_54,_55,_56,_57,_58,_59,_60, \ - _61,_62,_63,N,...) N - -#define _RSEQ_N() \ - 2,2,2,2, \ - 2,2,2,2,2,2,2,2,2,2, \ - 2,2,2,2,2,2,2,2,2,2, \ - 2,2,2,2,2,2,2,2,2,2, \ - 2,2,2,2,2,2,2,2,2,2, \ - 2,2,2,2,2,2,2,2,2,2, \ - 2,2,2,2,2,2,2,2,1,0 - -#define _VLOGFUNC_(name, n) name##n -#define _VLOGFUNC(name, n) _VLOGFUNC_(name, n) -#define VLOGFUNC(func, ...) _VLOGFUNC(func, _NARG__(__VA_ARGS__)) (__VA_ARGS__) - -// macro 'overloading': -// - call logDebug1 for one argument, logDebug2 for more than one argument (up to 64) -#define logDebug(...) VLOGFUNC(logDebug, __VA_ARGS__) -#define logDebug1(category) qCDebug(category).noquote() -#define logDebug2(...) qCDebug(__VA_ARGS__) - -#define logInfo(...) VLOGFUNC(logInfo, __VA_ARGS__) -#define logInfo1(category) qCInfo(category).noquote() -#define logInfo2(...) qCInfo(__VA_ARGS__) - -#define logWarn(...) VLOGFUNC(logWarning, __VA_ARGS__) -#define logWarning(...) VLOGFUNC(logWarning, __VA_ARGS__) -#define logWarning1(category) qCWarning(category).noquote() -#define logWarning2(...) qCWarning(__VA_ARGS__) - -#define logCritical(...) VLOGFUNC(logError, __VA_ARGS__) -#define logError(...) VLOGFUNC(logError, __VA_ARGS__) -#define logError1(category) qCCritical(category).noquote() -#define logError2(...) qCCritical(__VA_ARGS__) - -#define LOGGING_CATEGORY(cat, name) Q_LOGGING_CATEGORY(cat, "projecteur." name) -#define DECLARE_LOGGING_CATEGORY(name) extern const QLoggingCategory &name(); - -class QPlainTextEdit; - -namespace logging { - enum class level { - unknown = -1, - custom = 0, - debug = 1, - info = 2, - warning = 3, - error = 4 - }; - - const char* levelToString(level lvl); - level levelFromName(const QString& name); - level currentLevel(); - void setCurrentLevel(level lvl); - - void registerTextEdit(QPlainTextEdit* textEdit); - - QString hexId(uint16_t id); -} - - - diff --git a/src/main.cc b/src/main.cc index 26b452fa..c26abb37 100644 --- a/src/main.cc +++ b/src/main.cc @@ -4,10 +4,12 @@ #include "projecteurapp.h" #include "projecteur-GitVersion.h" -#include "logging.h" -#include "runguard.h" #include "settings.h" +#include +#include +#include + #include #ifndef NDEBUG @@ -21,16 +23,73 @@ #define XSTRINGIFY(s) STRINGIFY(s) #define STRINGIFY(x) #x -LOGGING_CATEGORY(appMain, "main") - namespace { // ----------------------------------------------------------------------------------------------- - constexpr int PROJECTEUR_ERROR_ANOTHER_INST_RUNNING = 42; constexpr int PROJECTEUR_ERROR_NO_INSTANCE_FOUND = 43; constexpr int PROJECTEUR_ERROR_EMPTY_COMMAND_PROPS = 44; // ----------------------------------------------------------------------------------------------- - class Main : public QObject {}; + KAboutData projecteurAboutData() + { + KAboutData aboutData( + QStringLiteral("Projecteur"), + QStringLiteral("Projecteur"), + QString::fromUtf8(projecteur::version_string()), + i18n("A KDE Plasma spotlight for Logitech presenter devices."), + KAboutLicense::MIT, + i18n("Copyright 2018–2021 Jahn Fuchs\n" + "Current development copyright 2026 Guillaume Binet"), + {}, + QStringLiteral("https://github.com/gbin/Projecteur"), + QStringLiteral("https://github.com/gbin/Projecteur/issues")); + + aboutData.setOrganizationDomain("projecteur.org"); + aboutData.setDesktopFileName(QStringLiteral("org.projecteur.Projecteur")); + aboutData.setOtherText( + i18n("Official KDE Plasma/Wayland edition of Projecteur.\n\n" + "Build information:\n" + "Git branch: %1\n" + "Git hash: %2\n" + "Build type: %3", + QString::fromUtf8(projecteur::version_branch()), + QString::fromUtf8(projecteur::version_shorthash()), + QString::fromUtf8(projecteur::version_buildtype()))); + + aboutData.addAuthor( + QStringLiteral("Guillaume Binet"), i18n("Projecteur maintainer"), {}, + QStringLiteral("https://github.com/gbin")); + aboutData.addAuthor( + QStringLiteral("Jahn Fuchs"), i18n("Original Projecteur author"), {}, + QStringLiteral("https://github.com/jahnf")); + + const struct { + const char* name; + const char* githubName; + } contributors[] = { + {"Ricardo Jesus", "rj-jesus"}, + {"Mayank Suman", "mayanksuman"}, + {"Tiziano Müller", "dev-zero"}, + {"Torsten Maehne", "maehne"}, + {"TBK", "TBK"}, + {"Louie Lu", "mlouielu"}, + {"fmuelle4711", "fmuelle4711"}, + {"Deniz Bahadir", "Bagira80"}, + {"Tomáš Chvátal", "scarabeusiv"}, + {"Brandon Johnson", "dbrandonjohnson"}, + {"Stuart Prescott", "llimeht"}, + {"Crista Renouard", "Lumnicence"}, + {"freddii", "freddii"}, + {"Matthias Blümel", "Blaimi"}, + {"Grzegorz Szymaszek", "gszy"}, + {"TheAssassin", "TheAssassin"}, + }; + for (const auto& contributor : contributors) { + aboutData.addCredit( + QString::fromUtf8(contributor.name), i18n("Contributor"), {}, + QStringLiteral("https://github.com/%1").arg(QString::fromUtf8(contributor.githubName))); + } + return aboutData; + } std::ostream& operator<<(std::ostream& os, const QString& s) { os << s.toStdString(); @@ -124,15 +183,16 @@ namespace { { const auto result = DeviceScan::getDevices(options.additionalDevices); print() << QCoreApplication::applicationName() << " " - << projecteur::version_string() << "; " << Main::tr("device scan") << std::endl; + << projecteur::version_string() << "; " << i18n("device scan") << std::endl; for (const auto& errmsg : result.errorMessages) { - print() << "** " << Main::tr("Error: ") << errmsg; + print() << "** " << i18n("Error: ") << errmsg; } print() << (!result.errorMessages.empty() ? "\n" : "") - << Main::tr(" * Found %1 supported devices. (%2 readable, %3 writable)") - .arg(result.devices.size()).arg(result.numDevicesReadable).arg(result.numDevicesWritable); + << i18np(" * Found one supported device. (%2 readable, %3 writable)", + " * Found %1 supported devices. (%2 readable, %3 writable)", + result.devices.size(), result.numDevicesReadable, result.numDevicesWritable); for (const auto& device : result.devices) { @@ -160,8 +220,8 @@ namespace { return subDevice.deviceWritable; }); - print() << " " << "vendorId: " << logging::hexId(device.id.vendorId); - print() << " " << "productId: " << logging::hexId(device.id.productId); + print() << " " << "vendorId: " << formatHexId(device.id.vendorId); + print() << " " << "productId: " << formatHexId(device.id.productId); print() << " " << "phys: " << device.id.phys; print() << " " << "busType: " << toString(device.id.busType); print() << " " << "devices: " << subDeviceList.join(", "); @@ -178,7 +238,7 @@ namespace { const uint16_t vendorId = devAttribs.size() > 0 ? devAttribs[0].toUShort(nullptr, 16) : 0; const uint16_t productId = devAttribs.size() > 1 ? devAttribs[1].toUShort(nullptr, 16) : 0; if (vendorId == 0 || productId == 0) { - error() << Main::tr("Invalid vendor/productId pair: ") << deviceValue; + error() << i18n("Invalid vendor/productId pair: ") << deviceValue; } else { const QString name = (devAttribs.size() >= 3) ? devAttribs[2] : ""; options.additionalDevices.push_back({vendorId, productId, false, name}); @@ -191,31 +251,31 @@ namespace { { QCommandLineParser parser; - const QCommandLineOption versionOption_ = {QStringList{ "v", "version"}, Main::tr("Print application version.")}; + const QCommandLineOption versionOption_ = {QStringList{ "v", "version"}, i18n("Print application version.")}; const QCommandLineOption fullVersionOption_ = QCommandLineOption{QStringList{ "f", "fullversion" }}; - const QCommandLineOption helpOption_ = {QStringList{ "h", "help"}, Main::tr("Show command line usage.")}; - const QCommandLineOption fullHelpOption_ = {QStringList{ "help-all"}, Main::tr("Show complete command line usage with all properties.")}; - const QCommandLineOption cfgFileOption_ = {QStringList{ "cfg" }, Main::tr("Set custom config file."), "file"}; - const QCommandLineOption commandOption_ = {QStringList{ "c", "command"}, Main::tr("Send command/property to a running instance."), "cmd"}; - const QCommandLineOption deviceInfoOption_ = {QStringList{ "d", "device-scan"}, Main::tr("Print device-scan results.")}; - const QCommandLineOption logLvlOption_ = {QStringList{ "l", "log-level" }, Main::tr("Set log level (dbg,inf,wrn,err)."), "lvl"}; - const QCommandLineOption disableUInputOption_ = {QStringList{ "disable-uinput" }, Main::tr("Disable uinput support.")}; - const QCommandLineOption showDlgOnStartOption_ = {QStringList{ "show-dialog" }, Main::tr("Show preferences dialog on start.")}; - const QCommandLineOption dialogMinOnlyOption_ = {QStringList{ "m", "minimize-only" }, Main::tr("Only allow minimizing the dialog.")}; - const QCommandLineOption disableOverlayOption_ = {QStringList{ "disable-overlay" }, Main::tr("Disable spotlight overlay completely.")}; + const QCommandLineOption helpOption_ = {QStringList{ "h", "help"}, i18n("Show command line usage.")}; + const QCommandLineOption fullHelpOption_ = {QStringList{ "help-all"}, i18n("Show complete command line usage with all properties.")}; + const QCommandLineOption cfgFileOption_ = {QStringList{ "cfg" }, i18n("Set custom config file."), "file"}; + const QCommandLineOption commandOption_ = {QStringList{ "c", "command"}, i18n("Send command/property to a running instance."), "cmd"}; + const QCommandLineOption deviceInfoOption_ = {QStringList{ "d", "device-scan"}, i18n("Print device-scan results.")}; + const QCommandLineOption disableUInputOption_ = {QStringList{ "disable-uinput" }, i18n("Disable uinput support.")}; + const QCommandLineOption showDlgOnStartOption_ = {QStringList{ "show-dialog" }, i18n("Show preferences dialog on start.")}; + const QCommandLineOption hideSysTrayOption_ = {QStringList{ "hide-systray-icon"}, i18n("Hide the system tray icon.")}; + const QCommandLineOption dialogMinOnlyOption_ = {QStringList{ "m", "minimize-only" }, i18n("Only allow minimizing the dialog.")}; + const QCommandLineOption disableOverlayOption_ = {QStringList{ "disable-overlay" }, i18n("Disable spotlight overlay completely.")}; const QCommandLineOption additionalDeviceOption_ = {QStringList{ "D", "additional-device"}, - Main::tr("Additional accepted device; DEVICE = vendorId:productId\n" + i18n("Additional accepted device; DEVICE = vendorId:productId\n" " " "e.g., -D 04b3:310c; e.g. -D 0x0c45:0x8101"), "device"}; // --------------------------------------------------------------------------------------------- ProjecteurCmdLineParser() { - parser.setApplicationDescription(Main::tr("Linux/X11 application for the Logitech Spotlight device.")); + parser.setApplicationDescription(i18n("Wayland application for the Logitech Spotlight device.")); parser.addOptions({versionOption_, helpOption_, fullHelpOption_, commandOption_, - cfgFileOption_, fullVersionOption_, deviceInfoOption_, logLvlOption_, + cfgFileOption_, fullVersionOption_, deviceInfoOption_, disableUInputOption_, showDlgOnStartOption_, dialogMinOnlyOption_, - disableOverlayOption_, additionalDeviceOption_}); + disableOverlayOption_, additionalDeviceOption_, hideSysTrayOption_}); } // --------------------------------------------------------------------------------------------- @@ -234,8 +294,7 @@ namespace { auto commandOptionValues() const { return parser.values(commandOption_); } bool cfgFileOptionSet() const { return parser.isSet(cfgFileOption_); } auto cfgFileOptionValue() const { return parser.value(cfgFileOption_); } - bool logLvlOptionSet() const { return parser.isSet(logLvlOption_); } - auto logLvlOptionValue() const { return parser.value(logLvlOption_); } + bool hideSysTrayOptionSet() const { return parser.isSet(hideSysTrayOption_); } // --------------------------------------------------------------------------------------------- void processArgs(int argc, char** argv) @@ -277,26 +336,26 @@ namespace { print() << " -v, --version " << versionOption_.description(); print() << " --cfg FILE " << cfgFileOption_.description(); print() << " -d, --device-scan " << deviceInfoOption_.description(); - print() << " -l, --log-level LEVEL " << logLvlOption_.description(); print() << " -D DEVICE " << additionalDeviceOption_.description(); if (fullHelp) { print() << " --disable-uinput " << disableUInputOption_.description(); print() << " --show-dialog " << showDlgOnStartOption_.description(); + print() << " --hide-systray-icon " << hideSysTrayOption_.description(); print() << " -m, --minimize-only " << dialogMinOnlyOption_.description(); } print() << " -c COMMAND|PROPERTY " << commandOption_.description() << std::endl; print() << ""; - print() << " spot=[on|off|toggle] " << Main::tr("Turn spotlight on/off or toggle."); + print() << " spot=[on|off|toggle] " << i18n("Turn spotlight on/off or toggle."); if (fullHelp) { - print() << " preset=NAME " << Main::tr("Set a preset."); - print() << " vibrate[=I[,L]] " << Main::tr("Send vibrate command to device with intensity,length."); - print() << " spot.size.adjust=[+|-]N " << Main::tr("Increase or decrease spot size by N."); + print() << " preset=NAME " << i18n("Set a preset."); + print() << " vibrate[=I[,L]] " << i18n("Send vibrate command to device with intensity,length."); + print() << " spot.size.adjust=[+|-]N " << i18n("Increase or decrease spot size by N."); } - print() << " settings=[show|hide] " << Main::tr("Show/hide preferences dialog."); + print() << " settings=[show|hide] " << i18n("Show/hide preferences dialog."); if (fullHelp) { - print() << " preset=NAME " << Main::tr("Set a preset."); + print() << " preset=NAME " << i18n("Set a preset."); } - print() << " quit " << Main::tr("Quit the running instance."); + print() << " quit " << i18n("Quit the running instance."); // Early return if the user not explicitly requested the full help if (!fullHelp) { return; } @@ -333,10 +392,15 @@ namespace { // ------------------------------------------------------------------------------------------------- int main(int argc, char *argv[]) { - QCoreApplication::setApplicationName("Projecteur"); - QCoreApplication::setApplicationVersion(projecteur::version_string()); + KLocalizedString::setApplicationDomain("projecteur"); + const auto aboutData = projecteurAboutData(); + KAboutData::setApplicationData(aboutData); + QCoreApplication::setApplicationName(aboutData.componentName()); + QCoreApplication::setOrganizationDomain(aboutData.organizationDomain()); + QCoreApplication::setApplicationVersion(aboutData.version()); + QGuiApplication::setApplicationDisplayName(aboutData.displayName()); + QGuiApplication::setDesktopFileName(aboutData.desktopFileName()); ProjecteurApplication::Options options; - QStringList ipcCommands; { ProjecteurCmdLineParser parser; parser.processArgs(argc, argv); @@ -368,14 +432,14 @@ int main(int argc, char *argv[]) // Check and trim ipc commands if set if (parser.commandOptionSet()) { - ipcCommands = parser.commandOptionValues(); - for (auto& value : ipcCommands) { + options.commands = parser.commandOptionValues(); + for (auto& value : options.commands) { value = value.trimmed(); } - ipcCommands.removeAll(""); + options.commands.removeAll(""); - if (ipcCommands.isEmpty()) { - error() << Main::tr("Command/Properties cannot be an empty string."); + if (options.commands.isEmpty()) { + error() << i18n("Command/Properties cannot be an empty string."); return PROJECTEUR_ERROR_EMPTY_COMMAND_PROPS; } } @@ -388,37 +452,42 @@ int main(int argc, char *argv[]) options.showPreferencesOnStart = parser.showDlgOnStartOptionSet(); options.dialogMinimizeOnly = parser.dialogMinOnlyOptionSet(); options.disableOverlay = parser.disableOverlayOptionSet(); + options.hideSysTrayIcon = parser.hideSysTrayOptionSet(); - if (parser.logLvlOptionSet()) { - const auto lvl = logging::levelFromName(parser.logLvlOptionValue()); - if (lvl != logging::level::unknown) { - logging::setCurrentLevel(lvl); - } else { - error() << Main::tr("Cannot set log level, unknown level: '%1'").arg(parser.logLvlOptionValue()); - } - } } - RunGuard guard(QCoreApplication::applicationName()); - if (!guard.tryToRun()) - { - if (ipcCommands.size() > 0) { - return ProjecteurCommandClientApp(ipcCommands, argc, argv).exec(); + ProjecteurApplication app(argc, argv, options); + if (!app.isPrimaryInstance()) { + if (app.startupExitCode() == PROJECTEUR_ERROR_NO_INSTANCE_FOUND) { + error() << i18n("Cannot send commands '%1' - no running application instance found.", + options.commands.join("; ")); } - error() << Main::tr("Another application instance is already running. Exiting."); - return PROJECTEUR_ERROR_ANOTHER_INST_RUNNING; + return app.startupExitCode(); } - if (ipcCommands.size() > 0) - { - // No other application instance running - but command option was used. - logInfo(appMain) << Main::tr("Cannot send commands '%1' - no running application instance found.").arg(ipcCommands.join("; ")); - logWarning(appMain) << Main::tr("Cannot send commands '%1' - no running application instance found.").arg(ipcCommands.join("; ")); - error() << Main::tr("Cannot send commands '%1' - no running application instance found.").arg(ipcCommands.join("; ")); - return PROJECTEUR_ERROR_NO_INSTANCE_FOUND; - } + QObject::connect(app.dbusService(), &KDBusService::activateRequested, &app, + [&app](const QStringList& arguments, const QString& /*workingDirectory*/) { + QStringList commands; + bool showPreferences = false; + for (qsizetype i = 1; i < arguments.size(); ++i) { + const auto& argument = arguments[i]; + if ((argument == QStringLiteral("-c") || argument == QStringLiteral("--command")) + && i + 1 < arguments.size()) { + commands.push_back(arguments[++i].trimmed()); + } else if (argument.startsWith(QStringLiteral("--command="))) { + commands.push_back(argument.mid(QStringLiteral("--command=").size()).trimmed()); + } else if (argument == QStringLiteral("--show-dialog")) { + showPreferences = true; + } + } + commands.removeAll(QString()); + if (!commands.isEmpty()) { + app.applyCommands(commands); + } else if (showPreferences) { + app.activate(); + } + }); - ProjecteurApplication app(argc, argv, options); signal(SIGINT, ctrl_c_signal_handler); return app.exec(); } diff --git a/src/nativekeyseqedit.cc b/src/nativekeyseqedit.cc index 1246fd7a..c5640e2e 100644 --- a/src/nativekeyseqedit.cc +++ b/src/nativekeyseqedit.cc @@ -5,7 +5,8 @@ #include "inputmapconfig.h" #include "inputseqedit.h" -#include "logging.h" + +#include #include @@ -92,20 +93,9 @@ QSize NativeKeySeqEdit::sizeHint() const constexpr int horizontalMargin = 3; const int h = opt.fontMetrics.height() + 2 * verticalMargin; - #if (QT_VERSION >= QT_VERSION_CHECK(5, 11, 0)) - const int w = std::max(opt.fontMetrics.horizontalAdvance(QLatin1Char('x')) * 17 + 2 * horizontalMargin, - opt.fontMetrics.horizontalAdvance(m_nativeSequence.toString())); - #else - const int w = std::max(opt.fontMetrics.width(QLatin1Char('x')) * 17 + 2 * horizontalMargin, - opt.fontMetrics.width(m_nativeSequence.toString())); - #endif - - #if (QT_VERSION < QT_VERSION_CHECK(6, 0, 0)) - return (style()->sizeFromContents(QStyle::CT_LineEdit, &opt, QSize(w, h). - expandedTo(QApplication::globalStrut()), this)); - #else + const int w = std::max(opt.fontMetrics.horizontalAdvance(QLatin1Char('x')) * 17 + 2 * horizontalMargin, + opt.fontMetrics.horizontalAdvance(m_nativeSequence.toString())); return style()->sizeFromContents(QStyle::CT_LineEdit, &opt, QSize(w, h), this); - #endif } // ------------------------------------------------------------------------------------------------- @@ -123,7 +113,7 @@ void NativeKeySeqEdit::paintEvent(QPaintEvent* /* event */) const int spacingX = static_cast(QStaticText(" ").size().width()); xPos += drawRecordingSymbol(xPos, p, option) + spacingX; if (m_recordedQtKeys.empty()) { - xPos += drawPlaceHolderText(xPos, p, option, tr("Press shortcut...")); + xPos += drawPlaceHolderText(xPos, p, option, i18n("Press shortcut...")); } else { xPos += drawText(xPos, p, option, NativeKeySequence::toString(m_recordedQtKeys, m_recordedNativeModifiers)); xPos += drawText(xPos, p, option, ", ..."); @@ -398,4 +388,3 @@ int NativeKeySeqEdit::drawSequence(int startX, QPainter& p, const QStyleOption& return drawText(startX, p, option, ks.toString()); } - diff --git a/src/org.projecteur.Projecteur.xml b/src/org.projecteur.Projecteur.xml new file mode 100644 index 00000000..3b380bb2 --- /dev/null +++ b/src/org.projecteur.Projecteur.xml @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/preferencesdlg.cc b/src/preferencesdlg.cc index 47020fce..48bd8f40 100644 --- a/src/preferencesdlg.cc +++ b/src/preferencesdlg.cc @@ -3,102 +3,90 @@ #include "preferencesdlg.h" -#include "projecteur-GitVersion.h" // auto generated version information - -#include "colorselector.h" #include "deviceswidget.h" #include "iconwidgets.h" -#include "logging.h" #include "settings.h" +#include +#include +#include +#include +#include +#include + #include #include #include -#include +#include #include -#include #include #include +#include #include #include #include #include -#include #include #include #include #include -#include -#include - -#if (QT_VERSION < QT_VERSION_CHECK(6, 0, 0)) - #if HAS_Qt_X11Extras - #include - #endif -#endif #include -LOGGING_CATEGORY(preferences, "preferences") -LOGGING_CATEGORY(x11display, "x11display") - // ------------------------------------------------------------------------------------------------- namespace { #define CURSOR_PATH ":/icons/cursors/" - static const std::map> cursorMap { - { "", {"No Cursor", Qt::BlankCursor}}, - { CURSOR_PATH "cursor-arrow.png", {"Arrow Cursor", Qt::ArrowCursor}}, - { CURSOR_PATH "cursor-busy.png", {"Busy Cursor", Qt::BusyCursor}}, - { CURSOR_PATH "cursor-cross.png", {"Cross Cursor", Qt::CrossCursor}}, - { CURSOR_PATH "cursor-hand.png", {"Pointing Hand Cursor", Qt::PointingHandCursor}}, - { CURSOR_PATH "cursor-openhand.png", {"Open Hand Cursor", Qt::OpenHandCursor}}, - { CURSOR_PATH "cursor-uparrow.png", {"Up Arrow Cursor", Qt::UpArrowCursor}}, - { CURSOR_PATH "cursor-whatsthis.png", {"What't This Cursor", Qt::WhatsThisCursor}}, + static const std::map> cursorMap { + { "", {kli18n("No Cursor"), Qt::BlankCursor}}, + { CURSOR_PATH "cursor-arrow.png", {kli18n("Arrow Cursor"), Qt::ArrowCursor}}, + { CURSOR_PATH "cursor-busy.png", {kli18n("Busy Cursor"), Qt::BusyCursor}}, + { CURSOR_PATH "cursor-cross.png", {kli18n("Cross Cursor"), Qt::CrossCursor}}, + { CURSOR_PATH "cursor-hand.png", {kli18n("Pointing Hand Cursor"), Qt::PointingHandCursor}}, + { CURSOR_PATH "cursor-openhand.png", {kli18n("Open Hand Cursor"), Qt::OpenHandCursor}}, + { CURSOR_PATH "cursor-uparrow.png", {kli18n("Up Arrow Cursor"), Qt::UpArrowCursor}}, + { CURSOR_PATH "cursor-whatsthis.png", {kli18n("What's This Cursor"), Qt::WhatsThisCursor}}, }; } // end anonymous namespace // ------------------------------------------------------------------------------------------------- PreferencesDialog::PreferencesDialog(Settings* settings, Spotlight* spotlight, + KActionCollection* actionCollection, Mode dialogMode, QWidget* parent) - : QDialog(parent) + : KConfigDialog(parent, QStringLiteral("preferences"), settings->configSkeleton()) + , m_settings(settings) + , m_actionCollection(actionCollection) , m_presetComboStyle(std::make_unique()) - , m_closeMinimizeBtn(new QPushButton(this)) - , m_exitBtn(new QPushButton(tr("&Quit %1").arg(QCoreApplication::applicationName()),this)) { - setWindowTitle(QCoreApplication::applicationName() + " - " + tr("Preferences")); + setAttribute(Qt::WA_DeleteOnClose, false); + setWindowTitle(QCoreApplication::applicationName() + " - " + i18n("Preferences")); setWindowIcon(QIcon(":/icons/projecteur-tray.svg")); + setFaceType(KPageDialog::Tabbed); setDialogMode(dialogMode); - connect(m_closeMinimizeBtn, &QPushButton::clicked, this, [this](){ - if (m_dialogMode == Mode::ClosableDialog) { this->close(); } - else { this->showMinimized(); } - }); - - connect(m_exitBtn, &QPushButton::clicked, this, [this](){ - emit exitApplicationRequested(); - }); const auto settingsWidget = createSettingsTabWidget(settings); settingsWidget->setDisabled(settings->overlayDisabled()); - const auto tabWidget = new QTabWidget(this); - tabWidget->addTab(settingsWidget, tr("Spotlight")); - m_deviceswidget = new DevicesWidget(settings, spotlight, this); - tabWidget->addTab(m_deviceswidget, tr("Devices")); - tabWidget->addTab(createLogTabWidget(), tr("Log")); - - const auto overlayCheckBox = new QCheckBox(this); + const auto spotlightPage = new QWidget(this); + const auto spotlightLayout = new QVBoxLayout(spotlightPage); + const auto overlayCheckBox = new QCheckBox(i18n("Enable spotlight overlay"), spotlightPage); overlayCheckBox->setChecked(!settings->overlayDisabled()); - tabWidget->tabBar()->setTabButton(0, QTabBar::ButtonPosition::LeftSide, overlayCheckBox); + spotlightLayout->addWidget(overlayCheckBox); + spotlightLayout->addWidget(settingsWidget); - const auto btnHBox = new QHBoxLayout; - btnHBox->addWidget(m_exitBtn); - btnHBox->addStretch(1); - btnHBox->addWidget(m_closeMinimizeBtn); - - const auto mainVBox = new QVBoxLayout(this); - mainVBox->addWidget(tabWidget); - mainVBox->addLayout(btnHBox); + addPage(spotlightPage, i18n("Spotlight"), QStringLiteral("preferences-desktop-display"), + QString(), false); + m_deviceswidget = new DevicesWidget(settings, spotlight, this); + addPage(m_deviceswidget, i18n("Devices"), QStringLiteral("input-mouse"), QString(), false); + m_shortcutsEditor = new KShortcutsEditor( + actionCollection, this, KShortcutsEditor::GlobalAction, + KShortcutsEditor::LetterShortcutsDisallowed); + addPage(m_shortcutsEditor, i18n("Shortcuts"), QStringLiteral("configure-shortcuts"), + QString(), false); + + if (auto* helpButton = buttonBox()->button(QDialogButtonBox::Help)) { + helpButton->hide(); + } connect(overlayCheckBox, &QCheckBox::toggled, this, [settings](bool checked){ settings->setOverlayDisabled(!checked); @@ -109,9 +97,39 @@ PreferencesDialog::PreferencesDialog(Settings* settings, Spotlight* spotlight, overlayCheckBox->setChecked(!disabled); settingsWidget->setDisabled(disabled); }); + + const auto modified = [this]() { settingsModified(); }; + connect(settings, &Settings::showSpotShadeChanged, this, modified); + connect(settings, &Settings::spotSizeChanged, this, modified); + connect(settings, &Settings::showCenterDotChanged, this, modified); + connect(settings, &Settings::dotSizeChanged, this, modified); + connect(settings, &Settings::dotColorChanged, this, modified); + connect(settings, &Settings::dotOpacityChanged, this, modified); + connect(settings, &Settings::shadeColorChanged, this, modified); + connect(settings, &Settings::shadeOpacityChanged, this, modified); + connect(settings, &Settings::cursorChanged, this, modified); + connect(settings, &Settings::spotShapeChanged, this, modified); + connect(settings, &Settings::spotRotationChanged, this, modified); + connect(settings, &Settings::showBorderChanged, this, modified); + connect(settings, &Settings::borderColorChanged, this, modified); + connect(settings, &Settings::borderSizeChanged, this, modified); + connect(settings, &Settings::borderOpacityChanged, this, modified); + connect(settings, &Settings::zoomEnabledChanged, this, modified); + connect(settings, &Settings::zoomFactorChanged, this, modified); + connect(settings, &Settings::zoomModeChanged, this, modified); + connect(settings, &Settings::multiScreenOverlayEnabledChanged, this, modified); + for (const auto& shape : Settings::spotShapes()) { + if (auto* shapeSettings = settings->shapeSettings(shape.name())) { + connect(shapeSettings, &QQmlPropertyMap::valueChanged, this, modified); + } + } + connect(m_shortcutsEditor, &KShortcutsEditor::keyChange, + this, &PreferencesDialog::updateButtons); + + m_appliedSpotlightSettings = settings->spotlightSettings(); + updateButtons(); } -// ------------------------------------------------------------------------------------------------- QWidget* PreferencesDialog::createSettingsTabWidget(Settings* settings) { const auto widget = new QWidget(this); @@ -130,28 +148,16 @@ QWidget* PreferencesDialog::createSettingsTabWidget(Settings* settings) const auto presetSelector = createPresetSelector(settings); - const auto resetBtn = new IconButton(Font::Icon::gear_12, widget); - resetBtn->setToolTip(tr("Reset all settings to their default value.")); - resetBtn->setSizePolicy(resetBtn->sizePolicy().horizontalPolicy(), QSizePolicy::Minimum); - connect(resetBtn, &QPushButton::clicked, settings, &Settings::setDefaults); - - const auto testBtn = new QPushButton(tr("&Show test..."), widget); + const auto testBtn = new QPushButton(i18n("&Show test..."), widget); connect(testBtn, &QPushButton::clicked, this, &PreferencesDialog::testButtonClicked); const auto hbox = new QHBoxLayout; - hbox->addWidget(resetBtn); hbox->addWidget(testBtn); - - const auto invisibleBtn = new QPushButton(this); - invisibleBtn->setVisible(false); - invisibleBtn->setDefault(true); + hbox->addStretch(1); const auto mainVBox = new QVBoxLayout(widget); mainVBox->addLayout(mainHBox); mainVBox->addWidget(presetSelector); -#if HAS_Qt_X11Extras - mainVBox->addWidget(createCompositorWarningWidget()); -#endif mainVBox->addLayout(hbox); return widget; @@ -163,7 +169,7 @@ QWidget* PreferencesDialog::createPresetSelector(Settings* settings) const auto widget = new QFrame(this); widget->setFrameStyle(QFrame::StyledPanel | QFrame::Plain); const auto hbox = new QHBoxLayout(widget); - hbox->addWidget(new QLabel(tr("Presets"), widget)); + hbox->addWidget(new QLabel(i18n("Presets"), widget)); m_presetCombo = new QComboBox(widget); m_presetCombo->setModel(settings->presetModel()); @@ -173,10 +179,10 @@ QWidget* PreferencesDialog::createPresetSelector(Settings* settings) m_presetCombo->setInsertPolicy(QComboBox::NoInsert); const auto deleteBtn = new IconButton(Font::Icon::trash_can_1, widget); - deleteBtn->setToolTip(tr("Delete currently selected preset.")); + deleteBtn->setToolTip(i18n("Delete currently selected preset.")); deleteBtn->setEnabled(m_presetCombo->currentIndex() > 0); const auto newBtn = new IconButton(Font::Icon::plus_5, widget); - newBtn->setToolTip(tr("Create new preset from current spotlight settings.")); + newBtn->setToolTip(i18n("Create new preset from current spotlight settings.")); const std::vector widgets{m_presetCombo, deleteBtn, newBtn}; for (const auto w : widgets) { @@ -228,7 +234,7 @@ QWidget* PreferencesDialog::createPresetSelector(Settings* settings) settings->savePreset(text); }); - le->setText(tr("New Preset")); + le->setText(i18n("New Preset")); le->setFocus(); le->selectAll(); }); @@ -256,68 +262,10 @@ QWidget* PreferencesDialog::createPresetSelector(Settings* settings) return widget; } -// ------------------------------------------------------------------------------------------------- -#if HAS_Qt_X11Extras -QWidget* PreferencesDialog::createCompositorWarningWidget() -{ - if (!QX11Info::isPlatformX11()) - { // Platform ist not X11, possibly wayland or others... - const auto widget = new QWidget(this); - widget->setVisible(false); - return widget; - } - - const auto widget = new QFrame(this); - widget->setFrameStyle(QFrame::StyledPanel | QFrame::Plain); - const auto hbox = new QHBoxLayout(widget); - - const auto iconLabel = new QLabel(this); - iconLabel->setPixmap(style()->standardPixmap(QStyle::SP_MessageBoxCritical)); - hbox->addWidget(iconLabel); - const auto textLabel = new QLabel(tr("Warning: No running compositing manager detected!"), this); - textLabel->setTextFormat(Qt::RichText); - textLabel->setToolTip(tr("Please make sure a compositing manager is running. " - "On some systems one way is to run xcompmgr manually.")); - hbox->addWidget(textLabel); - hbox->setStretch(1, 1); - - const auto timer = new QTimer(this); - timer->setInterval(1000); - timer->setSingleShot(false); - - auto checkForCompositorAndUpdate = [widget](){ - static bool compositorWasRunning = true; - const bool compositorIsRunning = QX11Info::isCompositingManagerRunning(); - if (compositorWasRunning != compositorIsRunning) - { - if (compositorIsRunning) { - logInfo(x11display) << tr("Detected running compositing compositing manager."); - } else { - logWarning(x11display) << tr("No running compositing manager detected."); - } - } - widget->setVisible(!compositorIsRunning); // Warning widget visible if no compositor is running. - compositorWasRunning = compositorIsRunning; - }; - - checkForCompositorAndUpdate(); - - connect(this, &PreferencesDialog::dialogActiveChanged, this, [timer, checkForCompositorAndUpdate](bool active) { - if (active) { checkForCompositorAndUpdate(); timer->start(); } else { timer->stop(); } - }); - - connect(timer, &QTimer::timeout, this, [checkForCompositorAndUpdate=std::move(checkForCompositorAndUpdate)]() { - checkForCompositorAndUpdate(); - }); - - return widget; -} -#endif - // ------------------------------------------------------------------------------------------------- QGroupBox* PreferencesDialog::createShapeGroupBox(Settings* settings) { - const auto shapeGroup = new QGroupBox(tr("Shape Settings"), this); + const auto shapeGroup = new QGroupBox(i18n("Shape Settings"), this); const auto spotSizeSpinBox = new QSpinBox(this); spotSizeSpinBox->setMaximum(settings->spotSizeRange().max); @@ -325,14 +273,14 @@ QGroupBox* PreferencesDialog::createShapeGroupBox(Settings* settings) spotSizeSpinBox->setValue(settings->spotSize()); const auto spotsizeHBox = new QHBoxLayout; spotsizeHBox->addWidget(spotSizeSpinBox); - spotsizeHBox->addWidget(new QLabel(QString("% ")+tr("of screen height"))); + spotsizeHBox->addWidget(new QLabel(QString("% ")+i18n("of screen height"))); connect(spotSizeSpinBox, static_cast(&QSpinBox::valueChanged), settings, &Settings::setSpotSize); connect(settings, &Settings::spotSizeChanged, spotSizeSpinBox, &QSpinBox::setValue); connect(settings, &Settings::spotSizeChanged, this, &PreferencesDialog::resetPresetCombo); const auto spotGrid = new QGridLayout(shapeGroup); - spotGrid->addWidget(new QLabel(tr("Spot Size"), this), 0, 0); + spotGrid->addWidget(new QLabel(i18n("Spot Size"), this), 0, 0); spotGrid->addLayout(spotsizeHBox, 0, 1); // Spotlight shape setting @@ -349,7 +297,7 @@ QGroupBox* PreferencesDialog::createShapeGroupBox(Settings* settings) resetPresetCombo(); }); emit settings->spotShapeChanged(settings->spotShape()); - spotGrid->addWidget(new QLabel(tr("Shape"), this), 4, 0); + spotGrid->addWidget(new QLabel(i18n("Shape"), this), 4, 0); spotGrid->addWidget(shapeCombo, 4, 1); // Spotlight rotation setting @@ -363,7 +311,7 @@ QGroupBox* PreferencesDialog::createShapeGroupBox(Settings* settings) settings, &Settings::setSpotRotation); connect(settings, &Settings::spotRotationChanged, shapeRotationSb, &QDoubleSpinBox::setValue); connect(settings, &Settings::spotRotationChanged, this, &PreferencesDialog::resetPresetCombo); - const auto shapeRotationLabel = new QLabel(tr("Rotation"), this); + const auto shapeRotationLabel = new QLabel(i18n("Rotation"), this); spotGrid->addWidget(shapeRotationLabel, 5, 0); spotGrid->addWidget(shapeRotationSb, 5, 1); @@ -405,11 +353,7 @@ QGroupBox* PreferencesDialog::createShapeGroupBox(Settings* settings) { if (row >= startRow + maxRows) { break; } spotGrid->addWidget(new QLabel(s.displayName(), this),row, 0); - #if (QT_VERSION < QT_VERSION_CHECK(6, 0, 0)) - if (s.defaultValue().type() == QVariant::Int) - #else if (s.defaultValue().metaType().id() == QMetaType::Int) - #endif { const auto spinbox = new QSpinBox(this); spinbox->setMaximum(s.maxValue().toInt()); @@ -459,7 +403,7 @@ QGroupBox* PreferencesDialog::createShapeGroupBox(Settings* settings) // ------------------------------------------------------------------------------------------------- QGroupBox* PreferencesDialog::createSpotGroupBox(Settings* settings) { - const auto spotGroup = new QGroupBox(tr("Show Spotlight Shade"), this); + const auto spotGroup = new QGroupBox(i18n("Show Spotlight Shade"), this); spotGroup->setCheckable(true); spotGroup->setChecked(settings->showSpotShade()); connect(spotGroup, &QGroupBox::toggled, settings, &Settings::setShowSpotShade); @@ -469,11 +413,12 @@ QGroupBox* PreferencesDialog::createSpotGroupBox(Settings* settings) const auto spotGrid = new QGridLayout(spotGroup); // Shade color setting - const auto shadeColor = new ColorSelector(tr("Select Shade Color"), settings->shadeColor(), this); - connect(shadeColor, &ColorSelector::colorChanged, settings, &Settings::setShadeColor); - connect(settings, &Settings::shadeColorChanged, shadeColor, &ColorSelector::setColor); + const auto shadeColor = new KColorButton(settings->shadeColor(), this); + shadeColor->setAccessibleName(i18n("Shade Color")); + connect(shadeColor, &KColorButton::changed, settings, &Settings::setShadeColor); + connect(settings, &Settings::shadeColorChanged, shadeColor, &KColorButton::setColor); connect(settings, &Settings::shadeColorChanged, this, &PreferencesDialog::resetPresetCombo); - spotGrid->addWidget(new QLabel(tr("Shade Color"), this), 1, 0); + spotGrid->addWidget(new QLabel(i18n("Shade Color"), this), 1, 0); spotGrid->addWidget(shadeColor, 1, 1); // Spotlight shade opacity setting @@ -487,7 +432,7 @@ QGroupBox* PreferencesDialog::createSpotGroupBox(Settings* settings) settings, &Settings::setShadeOpacity); connect(settings, &Settings::shadeOpacityChanged, shadeOpacitySb, &QDoubleSpinBox::setValue); connect(settings, &Settings::shadeOpacityChanged, this, &PreferencesDialog::resetPresetCombo); - spotGrid->addWidget(new QLabel(tr("Shade Opacity"), this), 2, 0); + spotGrid->addWidget(new QLabel(i18n("Shade Opacity"), this), 2, 0); spotGrid->addWidget(shadeOpacitySb, 2, 1); spotGrid->addWidget(new QWidget(this), 100, 0); @@ -500,7 +445,7 @@ QGroupBox* PreferencesDialog::createSpotGroupBox(Settings* settings) // ------------------------------------------------------------------------------------------------- QGroupBox* PreferencesDialog::createDotGroupBox(Settings* settings) { - const auto dotGroup = new QGroupBox(tr("Show Center Dot"), this); + const auto dotGroup = new QGroupBox(i18n("Show Center Dot"), this); dotGroup->setCheckable(true); dotGroup->setChecked(settings->showCenterDot()); connect(dotGroup, &QGroupBox::toggled, settings, &Settings::setShowCenterDot); @@ -513,21 +458,22 @@ QGroupBox* PreferencesDialog::createDotGroupBox(Settings* settings) dotSizeSpinBox->setValue(settings->dotSize()); auto dotsizeHBox = new QHBoxLayout; dotsizeHBox->addWidget(dotSizeSpinBox); - dotsizeHBox->addWidget(new QLabel(tr("pixel"))); + dotsizeHBox->addWidget(new QLabel(i18n("pixel"))); connect(dotSizeSpinBox, static_cast(&QSpinBox::valueChanged), settings, &Settings::setDotSize); connect(settings, &Settings::dotSizeChanged, dotSizeSpinBox, &QSpinBox::setValue); connect(settings, &Settings::dotSizeChanged, this, &PreferencesDialog::resetPresetCombo); const auto dotGrid = new QGridLayout(dotGroup); - dotGrid->addWidget(new QLabel(tr("Dot Size"), this), 0, 0); + dotGrid->addWidget(new QLabel(i18n("Dot Size"), this), 0, 0); dotGrid->addLayout(dotsizeHBox, 0, 1); - const auto dotColor = new ColorSelector(tr("Select Dot Color"), settings->dotColor(), this); - connect(dotColor, &ColorSelector::colorChanged, settings, &Settings::setDotColor); - connect(settings, &Settings::dotColorChanged, dotColor, &ColorSelector::setColor); + const auto dotColor = new KColorButton(settings->dotColor(), this); + dotColor->setAccessibleName(i18n("Dot Color")); + connect(dotColor, &KColorButton::changed, settings, &Settings::setDotColor); + connect(settings, &Settings::dotColorChanged, dotColor, &KColorButton::setColor); connect(settings, &Settings::dotColorChanged, this, &PreferencesDialog::resetPresetCombo); - dotGrid->addWidget(new QLabel(tr("Dot Color"), this), 1, 0); + dotGrid->addWidget(new QLabel(i18n("Dot Color"), this), 1, 0); dotGrid->addWidget(dotColor, 1, 1); @@ -542,7 +488,7 @@ QGroupBox* PreferencesDialog::createDotGroupBox(Settings* settings) settings, &Settings::setDotOpacity); connect(settings, &Settings::borderOpacityChanged, dotOpacitySb, &QDoubleSpinBox::setValue); connect(settings, &Settings::borderOpacityChanged, this, &PreferencesDialog::resetPresetCombo); - dotGrid->addWidget(new QLabel(tr("Dot Opacity"), this), 2, 0); + dotGrid->addWidget(new QLabel(i18n("Dot Opacity"), this), 2, 0); dotGrid->addWidget(dotOpacitySb, 2, 1); dotGrid->addWidget(new QWidget(this), 100, 0); @@ -555,7 +501,7 @@ QGroupBox* PreferencesDialog::createDotGroupBox(Settings* settings) // ------------------------------------------------------------------------------------------------- QGroupBox* PreferencesDialog::createBorderGroupBox(Settings* settings) { - const auto borderGroup = new QGroupBox(tr("Show Border"), this); + const auto borderGroup = new QGroupBox(i18n("Show Border"), this); borderGroup->setCheckable(true); borderGroup->setChecked(settings->showBorder()); connect(borderGroup, &QGroupBox::toggled, settings, &Settings::setShowBorder); @@ -568,21 +514,22 @@ QGroupBox* PreferencesDialog::createBorderGroupBox(Settings* settings) borderSizeSpinBox->setValue(settings->borderSize()); auto bordersizeHBox = new QHBoxLayout; bordersizeHBox->addWidget(borderSizeSpinBox); - bordersizeHBox->addWidget(new QLabel(tr("% of spotsize"))); + bordersizeHBox->addWidget(new QLabel(i18n("% of spotsize"))); connect(borderSizeSpinBox, static_cast(&QSpinBox::valueChanged), settings, &Settings::setBorderSize); connect(settings, &Settings::borderSizeChanged, borderSizeSpinBox, &QSpinBox::setValue); connect(settings, &Settings::borderSizeChanged, this, &PreferencesDialog::resetPresetCombo); const auto borderGrid = new QGridLayout(borderGroup); - borderGrid->addWidget(new QLabel(tr("Border Size"), this), 0, 0); + borderGrid->addWidget(new QLabel(i18n("Border Size"), this), 0, 0); borderGrid->addLayout(bordersizeHBox, 0, 1); - const auto borderColor = new ColorSelector(tr("Select Border Color"), settings->borderColor(), this); - connect(borderColor, &ColorSelector::colorChanged, settings, &Settings::setBorderColor); - connect(settings, &Settings::borderColorChanged, borderColor, &ColorSelector::setColor); + const auto borderColor = new KColorButton(settings->borderColor(), this); + borderColor->setAccessibleName(i18n("Border Color")); + connect(borderColor, &KColorButton::changed, settings, &Settings::setBorderColor); + connect(settings, &Settings::borderColorChanged, borderColor, &KColorButton::setColor); connect(settings, &Settings::borderColorChanged, this, &PreferencesDialog::resetPresetCombo); - borderGrid->addWidget(new QLabel(tr("Border Color"), this), 1, 0); + borderGrid->addWidget(new QLabel(i18n("Border Color"), this), 1, 0); borderGrid->addWidget(borderColor, 1, 1); // Spotlight border opacity setting @@ -596,7 +543,7 @@ QGroupBox* PreferencesDialog::createBorderGroupBox(Settings* settings) settings, &Settings::setBorderOpacity); connect(settings, &Settings::borderOpacityChanged, borderOpacitySb, &QDoubleSpinBox::setValue); connect(settings, &Settings::borderOpacityChanged, this, &PreferencesDialog::resetPresetCombo); - borderGrid->addWidget(new QLabel(tr("Border Opacity"), this), 2, 0); + borderGrid->addWidget(new QLabel(i18n("Border Opacity"), this), 2, 0); borderGrid->addWidget(borderOpacitySb, 2, 1); borderGrid->addWidget(new QWidget(this), 100, 0); @@ -609,7 +556,7 @@ QGroupBox* PreferencesDialog::createBorderGroupBox(Settings* settings) // ------------------------------------------------------------------------------------------------- QGroupBox* PreferencesDialog::createZoomGroupBox(Settings* settings) { - const auto zoomGroup = new QGroupBox(tr("Enable Zoom"), this); + const auto zoomGroup = new QGroupBox(i18n("Enable Zoom"), this); zoomGroup->setCheckable(true); zoomGroup->setChecked(settings->zoomEnabled()); connect(zoomGroup, &QGroupBox::toggled, settings, &Settings::setZoomEnabled); @@ -629,8 +576,29 @@ QGroupBox* PreferencesDialog::createZoomGroupBox(Settings* settings) settings, &Settings::setZoomFactor); connect(settings, &Settings::zoomFactorChanged, zoomLevelSb, &QDoubleSpinBox::setValue); connect(settings, &Settings::zoomFactorChanged, this, &PreferencesDialog::resetPresetCombo); - zoomGrid->addWidget(new QLabel(tr("Zoom Level"), this), 0, 0); + zoomGrid->addWidget(new QLabel(i18n("Zoom Level"), this), 0, 0); zoomGrid->addWidget(zoomLevelSb, 0, 1); + + const auto zoomModeCombo = new QComboBox(this); + zoomModeCombo->addItem(i18n("Smooth (images)"), QStringLiteral("smooth")); + zoomModeCombo->addItem(i18n("Text and UI"), QStringLiteral("text")); + zoomModeCombo->addItem(i18n("Pixel-perfect"), QStringLiteral("pixel")); + zoomModeCombo->setCurrentIndex(zoomModeCombo->findData(settings->zoomMode())); + zoomModeCombo->setToolTip( + i18n("Choose edge reconstruction for text, smooth filtering for images, " + "or nearest-neighbor scaling for pixel inspection.")); + connect(zoomModeCombo, &QComboBox::currentIndexChanged, settings, + [settings, zoomModeCombo](int index) { + settings->setZoomMode(zoomModeCombo->itemData(index).toString()); + }); + connect(settings, &Settings::zoomModeChanged, zoomModeCombo, + [zoomModeCombo](const QString& mode) { + const auto index = zoomModeCombo->findData(mode); + if (index >= 0) { zoomModeCombo->setCurrentIndex(index); } + }); + connect(settings, &Settings::zoomModeChanged, this, &PreferencesDialog::resetPresetCombo); + zoomGrid->addWidget(new QLabel(i18n("Content Type"), this), 1, 0); + zoomGrid->addWidget(zoomModeCombo, 1, 1); zoomGrid->setColumnStretch(1, 1); return zoomGroup; } @@ -638,13 +606,14 @@ QGroupBox* PreferencesDialog::createZoomGroupBox(Settings* settings) // ------------------------------------------------------------------------------------------------- QGroupBox* PreferencesDialog::createCursorGroupBox(Settings* settings) { - const auto cursorGroup = new QGroupBox(tr("Cursor Settings"), this); + const auto cursorGroup = new QGroupBox(i18n("Cursor Settings"), this); cursorGroup->setCheckable(false); const auto grid = new QGridLayout(cursorGroup); const auto cursorCb = new QComboBox(this); for (const auto& item : cursorMap) { - cursorCb->addItem(QIcon(item.first), item.second.first, static_cast(item.second.second)); + cursorCb->addItem( + QIcon(item.first), item.second.first.toString(), static_cast(item.second.second)); } connect(settings, &Settings::cursorChanged, cursorCb, [cursorCb, this](int cursor){ const int idx = cursorCb->findData(cursor); @@ -657,7 +626,7 @@ QGroupBox* PreferencesDialog::createCursorGroupBox(Settings* settings) settings->setCursor(static_cast(cursorCb->itemData(index).toInt())); }); - grid->addWidget(new QLabel(tr("Cursor"), this), 0, 0); + grid->addWidget(new QLabel(i18n("Cursor"), this), 0, 0); grid->addWidget(cursorCb, 0, 1); grid->setColumnStretch(1, 1); return cursorGroup; @@ -666,7 +635,7 @@ QGroupBox* PreferencesDialog::createCursorGroupBox(Settings* settings) // ------------------------------------------------------------------------------------------------- QWidget* PreferencesDialog::createMultiScreenWidget(Settings* settings) { - const auto cb = new QCheckBox(tr("Enable multi-screen overlay"), this); + const auto cb = new QCheckBox(i18n("Enable multi-screen overlay"), this); cb->setChecked(settings->multiScreenOverlayEnabled()); connect(cb, &QCheckBox::toggled, settings, &Settings::setMultiScreenOverlayEnabled); connect(settings, &Settings::multiScreenOverlayEnabledChanged, cb, &QCheckBox::setChecked); @@ -674,100 +643,6 @@ QWidget* PreferencesDialog::createMultiScreenWidget(Settings* settings) return cb; } -// ------------------------------------------------------------------------------------------------- -QWidget* PreferencesDialog::createLogTabWidget() -{ - const auto widget = new QWidget(this); - const auto mainVBox = new QVBoxLayout(widget); - - const auto te = new QPlainTextEdit(widget); - te->setReadOnly(true); - te->setWordWrapMode(QTextOption::NoWrap); - te->setMaximumBlockCount(1000); - te->setFont([te]() - { - auto font = te->font(); - font.setPointSize(font.pointSize() - 1); - return font; - }()); - logging::registerTextEdit(te); - - // Count discarded logs - connect(te, &QPlainTextEdit::blockCountChanged, this, - [maxBlockCount=te->maximumBlockCount(), this](int newBlockCount) { - if (newBlockCount > maxBlockCount) { - m_discardedLogCount += (newBlockCount-maxBlockCount); - } - }); - - const auto lvlHBox = new QHBoxLayout(); - lvlHBox->addWidget(new QLabel(tr("Log Level"), widget)); - // Log level combo box - const auto logLvlCombo = new QComboBox(widget); - logLvlCombo->addItem(tr("Debug"), static_cast(logging::level::debug)); - logLvlCombo->addItem(tr("Info"), static_cast(logging::level::info)); - logLvlCombo->addItem(tr("Warning"), static_cast(logging::level::warning)); - logLvlCombo->addItem(tr("Error"), static_cast(logging::level::error)); - lvlHBox->addWidget(logLvlCombo); - - const int idx = logLvlCombo->findData(static_cast(logging::currentLevel())); - logLvlCombo->setCurrentIndex((idx == -1) ? 0 : idx); - - connect(logLvlCombo, static_cast(&QComboBox::currentIndexChanged), this, - [logLvlCombo, te](int index) { - const auto lvl = static_cast(logLvlCombo->itemData(index).toInt()); - te->appendPlainText(tr("--- Setting new log level: %1").arg(logging::levelToString(lvl))); - logging::setCurrentLevel(lvl); - }); - - const auto saveLogBtn = new QPushButton(tr("&Save log..."), this); - saveLogBtn->setToolTip(tr("Save log to file.")); - connect(saveLogBtn, &QPushButton::clicked, this, [this, te]() - { - static auto saveDir = QDir::homePath(); - const auto defaultName = QString("projecteur_%1.log") - .arg(QDateTime::currentDateTime().toString("yyyy-MM-dd_hh-mm")); - - const auto defaultFile = QDir(saveDir).filePath(defaultName); - QString logFilter(tr("Log files (*.log *.txt)")); - const auto logFile = QFileDialog::getSaveFileName(this, tr("Save log file"), - defaultFile, logFilter, &logFilter); - if (logFile.isEmpty()) { return; } - saveDir = QFileInfo(logFile).path(); - - QFile f(logFile); - if (f.open(QIODevice::WriteOnly)) - { - f.write(QString("%1 %2\n").arg(QCoreApplication::applicationName()) - .arg(projecteur::version_string()).toLocal8Bit()); - f.write(QString(" - git-branch: %1, git-hash: %2\n").arg(projecteur::version_branch()) - .arg(projecteur::version_shorthash()).toLocal8Bit()); - f.write(QString(" - qt-version: (build: %1, runtime: %2)\n").arg(QT_VERSION_STR) - .arg(qVersion()).toLocal8Bit()); - f.write(QString("\n------------------------------------------------------------\n").toLocal8Bit()); - if (m_discardedLogCount > 0) { - f.write(tr("Discarded %1 previous log entries.").arg(m_discardedLogCount).toLocal8Bit()); - f.write(QString("\n------------------------------------------------------------\n").toLocal8Bit()); - } - f.write(te->toPlainText().toLocal8Bit()); - logInfo(preferences) << tr("Log saved to: ") << logFile; - } - else { - logError(preferences) << tr("Could not open '%1' for writing.").arg(logFile); - } - }); - - lvlHBox->addWidget(saveLogBtn); - lvlHBox->setStretch(0, 0); - lvlHBox->setStretch(1, 1); - lvlHBox->setStretch(2, 1); - - mainVBox->addLayout(lvlHBox); - mainVBox->addWidget(te); - return widget; -} - -// ------------------------------------------------------------------------------------------------- void PreferencesDialog::setMode(Mode dialogMode) { if (m_dialogMode == dialogMode) { @@ -785,18 +660,107 @@ void PreferencesDialog::setDialogMode(Mode dialogMode) if (dialogMode == Mode::ClosableDialog) { setWindowFlags(Qt::Dialog); - m_closeMinimizeBtn->setText(tr("&Close")); - m_closeMinimizeBtn->setToolTip(tr("Close the preferences dialog.")); } else if (dialogMode == Mode::MinimizeOnlyDialog) { setWindowFlags(Qt::Window); setWindowFlags(windowFlags() & ~Qt::WindowMaximizeButtonHint); setWindowFlags(windowFlags() & ~Qt::WindowCloseButtonHint); + } +} + +// ------------------------------------------------------------------------------------------------- +void PreferencesDialog::settingsModified() +{ + if (isVisible()) { + updateButtons(); + } else { + m_appliedSpotlightSettings = m_settings->spotlightSettings(); + } +} + +// ------------------------------------------------------------------------------------------------- +void PreferencesDialog::restoreAppliedSettings() +{ + m_shortcutsEditor->undo(); + m_settings->setSpotlightSettings(m_appliedSpotlightSettings); + resetPresetCombo(); + updateButtons(); +} + +// ------------------------------------------------------------------------------------------------- +bool PreferencesDialog::shortcutsAreDefault() const +{ + for (const auto* action : m_actionCollection->actions()) { + if (KGlobalAccel::self()->shortcut(action) + != KGlobalAccel::self()->defaultShortcut(action)) { + return false; + } + } + return true; +} + +// ------------------------------------------------------------------------------------------------- +void PreferencesDialog::updateSettings() +{ + KConfigDialog::updateSettings(); + m_shortcutsEditor->save(); + m_appliedSpotlightSettings = m_settings->spotlightSettings(); +} - m_closeMinimizeBtn->setText(tr("&Minimize")); - m_closeMinimizeBtn->setToolTip(tr("Minimize the preferences dialog.")); +// ------------------------------------------------------------------------------------------------- +void PreferencesDialog::updateWidgets() +{ + KConfigDialog::updateWidgets(); + restoreAppliedSettings(); +} + +// ------------------------------------------------------------------------------------------------- +void PreferencesDialog::updateWidgetsDefault() +{ + KConfigDialog::updateWidgetsDefault(); + m_settings->setDefaults(); + m_shortcutsEditor->allDefault(); + resetPresetCombo(); +} + +// ------------------------------------------------------------------------------------------------- +bool PreferencesDialog::hasChanged() +{ + return KConfigDialog::hasChanged() + || m_settings->spotlightSettings() != m_appliedSpotlightSettings + || m_shortcutsEditor->isModified(); +} + +// ------------------------------------------------------------------------------------------------- +bool PreferencesDialog::isDefault() +{ + return KConfigDialog::isDefault() + && m_settings->spotlightSettings() == Settings::defaultSpotlightSettings() + && shortcutsAreDefault(); +} + +// ------------------------------------------------------------------------------------------------- +void PreferencesDialog::accept() +{ + if (m_dialogMode == Mode::MinimizeOnlyDialog) { + updateSettings(); + updateButtons(); + showMinimized(); + return; + } + KConfigDialog::accept(); +} + +// ------------------------------------------------------------------------------------------------- +void PreferencesDialog::reject() +{ + restoreAppliedSettings(); + if (m_dialogMode == Mode::MinimizeOnlyDialog) { + showMinimized(); + return; } + KConfigDialog::reject(); } // ------------------------------------------------------------------------------------------------- @@ -825,15 +789,18 @@ bool PreferencesDialog::event(QEvent* e) else if (e->type() == QEvent::WindowDeactivate) { setDialogActive(false); } - return QDialog::event(e); + return KConfigDialog::event(e); } // ------------------------------------------------------------------------------------------------- -void PreferencesDialog::closeEvent(QCloseEvent* /* ev */) +void PreferencesDialog::closeEvent(QCloseEvent* e) { if (m_dialogMode == Mode::MinimizeOnlyDialog) { emit exitApplicationRequested(); + return; } + restoreAppliedSettings(); + KConfigDialog::closeEvent(e); } // ------------------------------------------------------------------------------------------------- @@ -847,7 +814,7 @@ void PreferencesDialog::keyPressEvent(QKeyEvent* e) return; } } - QDialog::keyPressEvent(e); + KConfigDialog::keyPressEvent(e); } // ------------------------------------------------------------------------------------------------- @@ -871,4 +838,3 @@ void PresetComboCustomStyle::drawControl(QStyle::ControlElement element, const Q } QProxyStyle::drawControl(element, option, painter, widget); } - diff --git a/src/preferencesdlg.h b/src/preferencesdlg.h index 21a9b8b3..7a01e0fd 100644 --- a/src/preferencesdlg.h +++ b/src/preferencesdlg.h @@ -2,14 +2,18 @@ // - See LICENSE.md and README.md #pragma once -#include +#include + #include #include +#include #include class QComboBox; class QGroupBox; +class KActionCollection; +class KShortcutsEditor; class Settings; class Spotlight; class DevicesWidget; @@ -23,7 +27,7 @@ class PresetComboCustomStyle : public QProxyStyle }; // ------------------------------------------------------------------------------------------------- -class PreferencesDialog : public QDialog +class PreferencesDialog : public KConfigDialog { Q_OBJECT @@ -34,6 +38,7 @@ class PreferencesDialog : public QDialog }; explicit PreferencesDialog(Settings* settings, Spotlight* spotlight, + KActionCollection* actionCollection, Mode = Mode::ClosableDialog, QWidget* parent = nullptr); virtual ~PreferencesDialog() override = default; @@ -41,19 +46,33 @@ class PreferencesDialog : public QDialog Mode mode() const { return m_dialogMode; } void setMode(Mode dialogMode); +public slots: + void accept() override; + void reject() override; + signals: void dialogActiveChanged(bool active); void testButtonClicked(); void exitApplicationRequested(); +protected slots: + void updateSettings() override; + void updateWidgets() override; + void updateWidgetsDefault() override; + protected: - virtual bool event(QEvent* event) override; - virtual void closeEvent(QCloseEvent* e) override; - virtual void keyPressEvent(QKeyEvent* e) override; + bool event(QEvent* event) override; + void closeEvent(QCloseEvent* e) override; + void keyPressEvent(QKeyEvent* e) override; + bool hasChanged() override; + bool isDefault() override; private: void setDialogActive(bool active); void setDialogMode(Mode dialogMode); + void settingsModified(); + void restoreAppliedSettings(); + bool shortcutsAreDefault() const; void resetPresetCombo(); QWidget* createSettingsTabWidget(Settings* settings); @@ -65,18 +84,15 @@ class PreferencesDialog : public QDialog QWidget* createMultiScreenWidget(Settings* settings); QGroupBox* createZoomGroupBox(Settings* settings); QWidget* createPresetSelector(Settings* settings); -#if HAS_Qt_X11Extras - QWidget* createCompositorWarningWidget(); -#endif - QWidget* createLogTabWidget(); private: + Settings* const m_settings; + KActionCollection* const m_actionCollection; + QVariantMap m_appliedSpotlightSettings; std::unique_ptr m_presetComboStyle; QComboBox* m_presetCombo = nullptr; - QPushButton* m_closeMinimizeBtn = nullptr; - QPushButton* m_exitBtn = nullptr; DevicesWidget* m_deviceswidget = nullptr; + KShortcutsEditor* m_shortcutsEditor = nullptr; bool m_active = false; Mode m_dialogMode = Mode::ClosableDialog; - quint32 m_discardedLogCount = 0; }; diff --git a/src/presentationtimer.cc b/src/presentationtimer.cc new file mode 100644 index 00000000..43555594 --- /dev/null +++ b/src/presentationtimer.cc @@ -0,0 +1,138 @@ +// This file is part of Projecteur - https://github.com/jahnf/projecteur +// - See LICENSE.md and README.md + +#include "presentationtimer.h" + +#include "device-command-helper.h" +#include "settings.h" +#include "spotlight.h" + +#include +#include + +#include + +namespace { +constexpr int MinimumDurationSeconds = 60; +constexpr int MaximumDurationSeconds = 180 * 60; +constexpr int TimerUpdateIntervalMs = 250; +} + +PresentationTimer::PresentationTimer(Settings* settings, Spotlight* spotlight, + DeviceCommandHelper* deviceCommandHelper, QObject* parent) + : QObject(parent) + , m_settings(settings) + , m_spotlight(spotlight) + , m_deviceCommandHelper(deviceCommandHelper) + , m_updateTimer(new QTimer(this)) + , m_enabled(settings->presentationTimerEnabled()) + , m_durationSeconds(std::clamp(settings->presentationTimerDurationSeconds(), + MinimumDurationSeconds, MaximumDurationSeconds)) + , m_remainingSeconds(m_durationSeconds) +{ + m_updateTimer->setTimerType(Qt::PreciseTimer); + m_updateTimer->setInterval(TimerUpdateIntervalMs); + connect(m_updateTimer, &QTimer::timeout, this, &PresentationTimer::updateRemaining); + connect(spotlight, &Spotlight::slideNavigationPressed, this, &PresentationTimer::start); +} + +QString PresentationTimer::stateName() const +{ + switch (m_state) { + case State::Idle: return QStringLiteral("idle"); + case State::Running: return QStringLiteral("running"); + case State::Completed: return QStringLiteral("completed"); + } + return QStringLiteral("idle"); +} + +void PresentationTimer::setEnabled(bool enabled) +{ + if (m_enabled == enabled) { return; } + m_enabled = enabled; + m_settings->setPresentationTimerEnabled(enabled); + emit enabledChanged(enabled); + if (!enabled) { reset(); } +} + +void PresentationTimer::start() +{ + if (!m_enabled) { return; } + if (m_state == State::Idle) { beginCountdown(); } +} + +void PresentationTimer::restart() +{ + if (!m_enabled) { return; } + beginCountdown(); +} + +void PresentationTimer::reset() +{ + m_updateTimer->stop(); + setRemainingSeconds(m_durationSeconds); + setState(State::Idle); +} + +void PresentationTimer::setDurationSeconds(int seconds) +{ + const int duration = std::clamp(seconds, MinimumDurationSeconds, MaximumDurationSeconds); + if (m_durationSeconds == duration) { return; } + + m_durationSeconds = duration; + m_settings->setPresentationTimerDurationSeconds(duration); + emit durationSecondsChanged(duration); + if (m_state == State::Idle) { setRemainingSeconds(duration); } +} + +void PresentationTimer::updateRemaining() +{ + if (m_state != State::Running) { return; } + + const auto remainingMs = + std::chrono::duration_cast(m_deadline - Clock::now()).count(); + if (remainingMs > 0) { + setRemainingSeconds(static_cast((remainingMs + 999) / 1000)); + return; + } + + m_updateTimer->stop(); + setRemainingSeconds(0); + setState(State::Completed); + + if (m_deviceCommandHelper && m_spotlight) + { + for (const auto& device : m_spotlight->connectedDevices()) + { + const int strength = std::clamp( + m_settings->devicePresentationTimerHapticStrength(device.id), 0, 100); + if (strength == 0) { continue; } + + const auto intensity = static_cast( + std::lround(static_cast(strength) * 255.0 / 100.0)); + m_deviceCommandHelper->sendVibrateCommand(device.id, intensity, 0); + } + } +} + +void PresentationTimer::beginCountdown() +{ + m_deadline = Clock::now() + std::chrono::seconds(m_durationSeconds); + setRemainingSeconds(m_durationSeconds); + setState(State::Running); + m_updateTimer->start(); +} + +void PresentationTimer::setState(State state) +{ + if (m_state == state) { return; } + m_state = state; + emit stateChanged(state); +} + +void PresentationTimer::setRemainingSeconds(int seconds) +{ + if (m_remainingSeconds == seconds) { return; } + m_remainingSeconds = seconds; + emit remainingSecondsChanged(seconds); +} diff --git a/src/presentationtimer.h b/src/presentationtimer.h new file mode 100644 index 00000000..2d5a6258 --- /dev/null +++ b/src/presentationtimer.h @@ -0,0 +1,63 @@ +// This file is part of Projecteur - https://github.com/jahnf/projecteur +// - See LICENSE.md and README.md +#pragma once + +#include + +#include + +class DeviceCommandHelper; +class QTimer; +class Settings; +class Spotlight; + +class PresentationTimer : public QObject +{ + Q_OBJECT + +public: + enum class State { Idle, Running, Completed }; + Q_ENUM(State) + + PresentationTimer(Settings* settings, Spotlight* spotlight, + DeviceCommandHelper* deviceCommandHelper, QObject* parent = nullptr); + + State state() const { return m_state; } + QString stateName() const; + bool enabled() const { return m_enabled; } + int durationSeconds() const { return m_durationSeconds; } + int remainingSeconds() const { return m_remainingSeconds; } + +public slots: + void setEnabled(bool enabled); + void start(); + void restart(); + void reset(); + void setDurationSeconds(int seconds); + +signals: + void enabledChanged(bool enabled); + void stateChanged(PresentationTimer::State state); + void durationSecondsChanged(int seconds); + void remainingSecondsChanged(int seconds); + +private slots: + void updateRemaining(); + +private: + using Clock = std::chrono::steady_clock; + + void beginCountdown(); + void setState(State state); + void setRemainingSeconds(int seconds); + + Settings* const m_settings; + Spotlight* const m_spotlight; + DeviceCommandHelper* const m_deviceCommandHelper; + QTimer* const m_updateTimer; + Clock::time_point m_deadline; + bool m_enabled = false; + State m_state = State::Idle; + int m_durationSeconds = 15 * 60; + int m_remainingSeconds = m_durationSeconds; +}; diff --git a/src/projecteurapp.cc b/src/projecteurapp.cc index 378b3c6e..3dcf7cba 100644 --- a/src/projecteurapp.cc +++ b/src/projecteurapp.cc @@ -3,59 +3,88 @@ #include "projecteurapp.h" -#include "aboutdlg.h" #include "device-command-helper.h" #include "imageitem.h" #include "linuxdesktop.h" -#include "logging.h" #include "preferencesdlg.h" +#include "presentationtimer.h" +#include "projecteur_command_debug.h" +#include "projecteur_main_debug.h" +#include "projecteurcontrol.h" #include "settings.h" #include "spotlight.h" -#if (QT_VERSION < QT_VERSION_CHECK(6, 0, 0)) -#include -#endif - +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include #include -#include -#include -#include -#include +#include #include +#include +#include #include #include #include #include #include -#include #include #include -LOGGING_CATEGORY(mainapp, "mainapp") -LOGGING_CATEGORY(cmdclient, "cmdclient") -LOGGING_CATEGORY(cmdserver, "cmdserver") +#include namespace { - QString localServerName() { - return QCoreApplication::applicationName() + "_local_socket"; - } -} // end anonymous namespace +constexpr auto notificationComponent = "projecteur"; + +void sendNotification(const QString& eventId, const QString& title, const QString& text, + const QString& iconName = QStringLiteral("projecteur")) +{ + KNotification::event( + eventId, title, text, iconName, KNotification::CloseOnTimeout, + QString::fromLatin1(notificationComponent)); +} +} // ------------------------------------------------------------------------------------------------- ProjecteurApplication::ProjecteurApplication(int &argc, char **argv, const Options& options) : QApplication(argc, argv) - , m_trayIcon(new QSystemTrayIcon()) - , m_trayMenu(new QMenu()) - , m_localServer(new QLocalServer(this)) - , m_linuxDesktop(new LinuxDesktop(this)) - , m_xcbOnWayland(QGuiApplication::platformName() == "xcb" && m_linuxDesktop->isWayland()) { + m_dbusService = new KDBusService( + KDBusService::Unique | KDBusService::NoExitOnFailure, this); + if (!m_dbusService->isRegistered()) { + return; + } + m_primaryInstance = true; + setWindowIcon(QIcon::fromTheme( + QStringLiteral("projecteur"), QIcon(QStringLiteral(":/icons/projecteur-tray.svg")))); + + if (!options.commands.isEmpty()) { + const auto commands = options.commands.join(QStringLiteral("; ")); + qCWarning(PROJECTEUR_MAIN_LOG).noquote() + << QStringLiteral("Cannot send commands '%1' - no running application instance found.").arg(commands); + m_startupExitCode = 43; + m_dbusService->unregister(); + m_primaryInstance = false; + return; + } + + m_linuxDesktop = new LinuxDesktop(this); + if (screens().empty()) { - const auto title = tr("No Screens detected"); - const auto text = tr("screens().size() returned a size < 1. Exiting."); - logError(mainapp) << title << ";" << text; - QMessageBox::critical(nullptr, title, text); + const auto title = i18n("No Screens detected"); + const auto text = i18n("screens().size() returned a size < 1. Exiting."); + qCCritical(PROJECTEUR_MAIN_LOG).noquote() + << "No screens detected; screens().size() returned a size below one. Exiting."; + KMessageBox::error(nullptr, text, title); QTimer::singleShot(0, this, [this](){ this->exit(2); }); return; } @@ -70,9 +99,14 @@ ProjecteurApplication::ProjecteurApplication(int &argc, char **argv, const Optio m_settings); m_deviceCommandHelper = new DeviceCommandHelper(this, m_spotlight); + m_presentationTimer = + new PresentationTimer(m_settings, m_spotlight, m_deviceCommandHelper, this); m_settings->setOverlayDisabled(options.disableOverlay); - m_dialog = std::make_unique(m_settings, m_spotlight, + setupControlService(options); + setupGlobalShortcuts(); + + m_dialog = std::make_unique(m_settings, m_spotlight, m_actionCollection, options.dialogMinimizeOnly ? PreferencesDialog::Mode::MinimizeOnlyDialog : PreferencesDialog::Mode::ClosableDialog); @@ -80,20 +114,20 @@ ProjecteurApplication::ProjecteurApplication(int &argc, char **argv, const Optio connect(&*m_dialog, &PreferencesDialog::testButtonClicked, this, [this](){ m_spotlight->setSpotActive(true); }); + connect(&*m_dialog, &PreferencesDialog::exitApplicationRequested, this, [this]() { + qCDebug(PROJECTEUR_MAIN_LOG).noquote() << QStringLiteral("Exit request from preferences dialog."); + quit(); + }); - const QString desktopEnv = m_linuxDesktop->type() == LinuxDesktop::Type::KDE ? "KDE" : - m_linuxDesktop->type() == LinuxDesktop::Type::Gnome ? "Gnome" - : tr("Unknown"); - - logDebug(mainapp) << tr("Qt platform plugin: %1;").arg(QGuiApplication::platformName()) - << tr("Desktop Environment: %1;").arg(desktopEnv) - << tr("Wayland: %1").arg(m_linuxDesktop->isWayland() ? "true" : "false"); + const QString desktopEnv = m_linuxDesktop->type() == LinuxDesktop::Type::KDE + ? QStringLiteral("KDE") + : QStringLiteral("Unknown"); - if (m_xcbOnWayland) { - logWarning(mainapp) << tr("Qt 'xcb' platform and Wayland session detected."); - } + qCDebug(PROJECTEUR_MAIN_LOG).noquote() << QStringLiteral("Qt platform plugin: %1;").arg(QGuiApplication::platformName()) + << QStringLiteral("Desktop Environment: %1;").arg(desktopEnv) + << QStringLiteral("Wayland: %1").arg(m_linuxDesktop->isWayland() ? "true" : "false"); - if (options.showPreferencesOnStart || m_linuxDesktop->isWayland()) { + if (options.showPreferencesOnStart) { QTimer::singleShot(0, this, [this](){ showPreferences(true); }); } else if (options.dialogMinimizeOnly) { @@ -109,15 +143,18 @@ ProjecteurApplication::ProjecteurApplication(int &argc, char **argv, const Optio // Create qml overlay window component m_windowQmlComponent = new QQmlComponent(m_qmlEngine, QUrl(QStringLiteral("qrc:/main.qml")), m_qmlEngine); if (m_windowQmlComponent->status() != QQmlComponent::Status::Ready) { - const auto title = tr("Overlay window error."); - const auto text = tr("Qml component has status '%1'. Exiting.").arg(m_windowQmlComponent->status()); + const auto title = i18n("Overlay window error."); + const auto text = i18n("Qml component has status '%1'. Exiting.", + static_cast(m_windowQmlComponent->status())); - logError(mainapp) << title << ";" << text; + qCCritical(PROJECTEUR_MAIN_LOG).noquote() + << "Overlay QML component has unexpected status:" + << static_cast(m_windowQmlComponent->status()); for (const auto& error : m_windowQmlComponent->errors()) { - logError(mainapp) << error.toString(); + qCCritical(PROJECTEUR_MAIN_LOG).noquote() << error.toString(); } - QMessageBox::critical(nullptr, title, text); + KMessageBox::error(nullptr, text, title); QTimer::singleShot(0, this, [this](){ this->exit(2); }); return; } @@ -132,80 +169,32 @@ ProjecteurApplication::ProjecteurApplication(int &argc, char **argv, const Optio if (m_spotlight->spotActive()) { m_spotlight->setSpotActive(false); } else { emit m_spotlight->spotActiveChanged(false); } } - else { - QTimer::singleShot(0, this, [this](){ - if (m_spotlight->spotActive()) { - emit m_spotlight->spotActiveChanged(true); - } else { - m_spotlight->setSpotActive(true); - } - }); - } }); // Re-setup screen overlay(s) when a screen is added or removed connect(this, &ProjecteurApplication::screenAdded, this, [this](){ setupScreenOverlays(); }); connect(this, &ProjecteurApplication::screenRemoved, this, [this](){ setupScreenOverlays(); }); - // Setup the tray icon and menu - setupTrayIcon(); + setupNotifications(); connect(this, &ProjecteurApplication::aboutToQuit, this, [this](){ - for (const auto window : m_overlayWindows) { window->close(); } + m_linuxDesktop->setShakeCursorEffectSuppressed(false); + for (const auto window : m_overlayWindows) { delete window; } m_overlayWindows.clear(); + m_screenWindowMap.clear(); }); // Setup the spotlight connections. setupSpotlight(); - - // Open local server for local IPC commands, e.g. from other command line instances - QLocalServer::removeServer(localServerName()); - if (m_localServer->listen(localServerName())) - { - connect(m_localServer, &QLocalServer::newConnection, this, [this]() - { - while(QLocalSocket *clientConnection = m_localServer->nextPendingConnection()) - { - connect(clientConnection, &QLocalSocket::readyRead, this, [this, clientConnection]() { - this->readCommand(clientConnection); - }); - connect(clientConnection, &QLocalSocket::disconnected, this, [this, clientConnection]() { - const auto it = m_commandConnections.find(clientConnection); - if (it != m_commandConnections.end()) - { - quint32& commandSize = it->second; - while (clientConnection->bytesAvailable() && commandSize <= clientConnection->bytesAvailable()) { - this->readCommand(clientConnection); - } - m_commandConnections.erase(it); - } - clientConnection->close(); - clientConnection->deleteLater(); - }); - - // Timeout timer - if after 5 seconds the connection is still open just disconnect... - const auto clientConnPtr = QPointer(clientConnection); - QTimer::singleShot(5000, clientConnection, [clientConnPtr](){ - if (clientConnPtr) { - // time out - clientConnPtr->disconnectFromServer(); - } - }); - - m_commandConnections.emplace(clientConnection, 0); - } - }); - } - else - { - logError(cmdserver) << tr("Error starting local socket for inter-process communication."); - } } // ------------------------------------------------------------------------------------------------- ProjecteurApplication::~ProjecteurApplication() { - if (m_localServer) { m_localServer->close(); } + if (m_control) { m_control->unregisterObject(); } + for (const auto window : m_overlayWindows) { delete window; } + m_overlayWindows.clear(); + m_screenWindowMap.clear(); } // ------------------------------------------------------------------------------------------------- @@ -217,19 +206,40 @@ void ProjecteurApplication::setupSpotlight() { if (active && !m_settings->overlayDisabled()) { - if (!m_settings->multiScreenOverlayEnabled()) { setScreenForCursorPos(); } + m_linuxDesktop->setShakeCursorEffectSuppressed(true); + + QScreen* const cursorScreen = screenAtCursorPos(); + if (!m_settings->multiScreenOverlayEnabled()) { + updateOverlayWindow(m_overlayWindows.first(), cursorScreen); + } + if (cursorScreen) { + setCurrentSpotScreen(quint64(cursorScreen)); + } for (const auto window : m_overlayWindows) { - window->setFlags(window->flags() | Qt::WindowStaysOnTopHint); - window->setFlags(window->flags() & ~Qt::SplashScreen); - window->setFlags(window->flags() | Qt::ToolTip); - window->setFlags(window->flags() & ~Qt::WindowTransparentForInput); - if (window->screen()) { if (m_settings->zoomEnabled()) { - window->setProperty("desktopPixmap", m_linuxDesktop->grabScreen(window->screen())); + auto* stream = window->property("desktopStream").value(); + const auto streamScreenId = + window->property("desktopStreamScreenId").toULongLong(); + const auto currentScreenId = quint64(window->screen()); + if (stream && streamScreenId != currentScreenId) { + window->setProperty("desktopStream", + QVariant::fromValue(nullptr)); + stream->deleteLater(); + stream = nullptr; + } + if (!stream) { + stream = m_linuxDesktop->streamScreen(window->screen(), window); + window->setProperty("desktopStream", QVariant::fromValue(stream)); + window->setProperty("desktopStreamScreenId", currentScreenId); + } + if (!stream) { + window->setProperty("desktopPixmap", + m_linuxDesktop->grabScreen(window->screen())); + } } const auto screenGeometry = window->screen()->geometry(); @@ -238,118 +248,219 @@ void ProjecteurApplication::setupSpotlight() } window->setPosition(screenGeometry.topLeft()); } - window->showFullScreen(); - window->raise(); + window->show(); } m_overlayVisible = true; emit overlayVisibleChanged(true); } else { + m_linuxDesktop->setShakeCursorEffectSuppressed(false); + m_overlayVisible = false; emit overlayVisibleChanged(false); for (const auto window : m_overlayWindows) { - window->setFlags(window->flags() | Qt::WindowTransparentForInput); - window->setFlags(window->flags() & ~Qt::WindowStaysOnTopHint); - // Workaround for 'xcb' on Wayland session (default on Ubuntu) - // .. the window in that case is not transparent for inputs and cannot be clicked through. - // --> hide the window, although animations will not be visible - if (m_xcbOnWayland) { window->hide(); } - } - if (m_xcbOnWayland && m_dialog->mode() == PreferencesDialog::Mode::MinimizeOnlyDialog - && m_dialog->isMinimized()) { // keep Window minimized... - //Workaround for QTBUG-76354 (https://bugreports.qt.io/browse/QTBUG-76354) - m_dialog->showNormal(); - m_dialog->setWindowState(Qt::WindowMinimized); + QTimer::singleShot(200, window, [this, window]() { + if (!m_spotlight->spotActive()) { + window->hide(); + } + }); } } }); connect(m_spotlight, &Spotlight::spotActiveChanged, this, [this](bool active){ if (!active && m_dialog->isVisible()) { - m_dialog->raise(); - m_dialog->activateWindow(); + showAndActivate(m_dialog.get()); } }); } +void ProjecteurApplication::setupControlService(Options const& options) +{ + m_control = new ProjecteurControl(this, m_settings, m_spotlight, m_presentationTimer, + !options.hideSysTrayIcon); + if (!m_control->registerObject()) { + qCCritical(PROJECTEUR_MAIN_LOG).noquote() << QStringLiteral("Could not register the Projecteur D-Bus control object."); + } +} + // ------------------------------------------------------------------------------------------------- -void ProjecteurApplication::setupTrayIcon() +void ProjecteurApplication::setupGlobalShortcuts() { - // add and connect 'Preferences' tray menu action - const auto actionPref = m_trayMenu->addAction(tr("&Preferences...")); - connect(actionPref, &QAction::triggered, this, [this](){ - this->showPreferences(true); - }); + m_actionCollection = new KActionCollection(this); + m_actionCollection->setComponentDisplayName(i18n("Projecteur")); - // add and and connect 'About' tray menu action - const auto actionAbout = m_trayMenu->addAction(tr("&About")); - connect(actionAbout, &QAction::triggered, this, [this]() - { - if (!m_aboutDialog) { - m_aboutDialog = new AboutDialog(); - connect(m_aboutDialog, &QDialog::finished, this, [this](int /* result */) { - m_aboutDialog->deleteLater(); // No need to keep about dialog in memory, not that important - }); - } + const auto addAction = + [this](const QString& id, const QString& text, const QString& iconName, auto callback) + { + auto* action = new QAction(QIcon::fromTheme(iconName), text, m_actionCollection); + m_actionCollection->addAction(id, action); + connect(action, &QAction::triggered, this, std::move(callback)); + if (!KGlobalAccel::setGlobalShortcut(action, QList{})) { + qCWarning(PROJECTEUR_MAIN_LOG).noquote() << QStringLiteral("Could not register global shortcut action '%1'.").arg(id); + } + }; + + addAction( + QStringLiteral("toggle_spotlight"), i18n("Toggle Spotlight"), + QStringLiteral("view-visible"), + [this]() { + if (!m_settings->overlayDisabled()) { + m_control->SetSpotlightActive(!m_control->spotlightActive()); + } + }); + addAction( + QStringLiteral("show_preferences"), i18n("Show Preferences"), + QStringLiteral("configure"), + [this]() { m_control->ShowPreferences(); }); + addAction( + QStringLiteral("start_restart_timer"), i18n("Start or Restart Presentation Timer"), + QStringLiteral("chronometer"), + [this]() { m_control->RestartTimer(); }); + addAction( + QStringLiteral("reset_timer"), i18n("Reset Presentation Timer"), + QStringLiteral("edit-undo"), + [this]() { m_control->ResetTimer(); }); + addAction( + QStringLiteral("next_preset"), i18n("Next Spotlight Preset"), + QStringLiteral("go-next"), + [this]() { m_control->loadNextPreset(); }); + addAction( + QStringLiteral("previous_preset"), i18n("Previous Spotlight Preset"), + QStringLiteral("go-previous"), + [this]() { m_control->loadPreviousPreset(); }); +} - if (m_aboutDialog->isVisible()) { - m_aboutDialog->show(); - m_aboutDialog->raise(); - m_aboutDialog->activateWindow(); - } else { - m_aboutDialog->open(); +// ------------------------------------------------------------------------------------------------- +void ProjecteurApplication::setupNotifications() +{ + connect(m_presentationTimer, &PresentationTimer::stateChanged, this, + [this](PresentationTimer::State state) { + if (state == PresentationTimer::State::Completed) { + sendNotification( + QStringLiteral("presentationTimerFinished"), + i18n("Presentation timer finished"), + i18n("The configured presentation time has elapsed."), + QStringLiteral("chronometer")); } }); - m_trayMenu->addSeparator(); - const auto actionQuit = m_trayMenu->addAction(tr("&Quit")); - connect(actionQuit, &QAction::triggered, this, [this](){ - m_qmlEngine->deleteLater(); // see: https://bugreports.qt.io/browse/QTBUG-81247 - this->quit(); + connect(m_spotlight, &Spotlight::deviceConnected, this, + [this](const DeviceId&, const QString& name) { + sendNotification( + QStringLiteral("presenterConnected"), + i18n("Presenter connected"), + i18n("%1 is ready.", name), + QStringLiteral("input-mouse")); + }); + connect(m_spotlight, &Spotlight::deviceDisconnected, this, + [this](const DeviceId&, const QString& name) { + sendNotification( + QStringLiteral("presenterDisconnected"), + i18n("Presenter disconnected"), + i18n("%1 is no longer available.", name), + QStringLiteral("input-mouse")); }); - m_trayIcon->setContextMenu(&*m_trayMenu); - m_trayIcon->setIcon(QIcon(":/icons/projecteur-tray-64.png")); - m_trayIcon->show(); + const auto inaccessiblePaths = std::make_shared>(); + connect(m_spotlight, &Spotlight::deviceAccessError, this, + [this, inaccessiblePaths](const QString& name, const QString& path) { + if (inaccessiblePaths->contains(path)) { return; } + inaccessiblePaths->insert(path); + sendNotification( + QStringLiteral("deviceAccessError"), + i18n("Presenter access failed"), + i18n("%1 cannot access %2. Check the installed udev rules and device permissions.", + name, path), + QStringLiteral("dialog-warning")); + }); + connect(m_spotlight, &Spotlight::subDeviceConnected, this, + [inaccessiblePaths](const DeviceId&, const QString&, const QString& path) { + inaccessiblePaths->remove(path); + }); - connect(&*m_trayIcon, &QSystemTrayIcon::activated, this, - [this](QSystemTrayIcon::ActivationReason reason) { - if (reason == QSystemTrayIcon::Trigger) - { - const auto trayGeometry = m_trayIcon->geometry(); - // This usually won't give us a valid geometry, since Qt isn't drawing the tray icon itself - if (trayGeometry.isValid()) { - m_trayIcon->contextMenu()->popup(m_trayIcon->geometry().center()); - } else { - // It's tricky to get the same behavior on all desktop environments. While on GNOME3 - // it behaves as one (or most) would expect, it behaves differently on other Desktop - // environments. - // QSystemTrayIcon is a wrapper around the StatusNotfierItem on modern (Linux) Desktops - // see: https://www.freedesktop.org/wiki/Specifications/StatusNotifierItem/ - // Via the Qt API there is not much control over how e.g. KDE or GNOME show the icon - // and how it behaves.. e.g. setting something like - // org.freedesktop.StatusNotifierItem.ItemIsMenu to True would be good for KDE Plasma - // see: https://www.freedesktop.org/wiki/Specifications/StatusNotifierItem/StatusNotifierItem/ - this->showPreferences(true); - } + const auto batteryWarnings = std::make_shared>(); + connect(m_control, &ProjecteurControl::batteryStateChanged, this, + [this, batteryWarnings](const QString& name, int level, const QString& status) { + QString warningKey; + QString eventId; + QString title; + QString text; + QString iconName; + + if (status == QStringLiteral("invalid-battery") + || status == QStringLiteral("thermal-error") + || status == QStringLiteral("charging-error")) { + warningKey = QStringLiteral("error:") + status; + eventId = QStringLiteral("presenterBatteryError"); + title = i18n("Presenter battery problem"); + text = i18n("%1 reported a battery error: %2.", name, status); + iconName = QStringLiteral("dialog-warning"); + } else if (level >= 0 && level <= 20 && status == QStringLiteral("discharging")) { + warningKey = QStringLiteral("low"); + eventId = QStringLiteral("presenterBatteryLow"); + title = i18n("Presenter battery low"); + text = i18n("%1 has %2% battery remaining.", name, level); + iconName = QStringLiteral("battery-low"); } - }); - connect(&*m_dialog, &PreferencesDialog::exitApplicationRequested, actionQuit, [actionQuit]() { - logDebug(mainapp) << tr("Exit request from preferences dialog."); - actionQuit->trigger(); + if (warningKey.isEmpty()) { + batteryWarnings->remove(name); + return; + } + if (batteryWarnings->value(name) == warningKey) { return; } + batteryWarnings->insert(name, warningKey); + sendNotification(eventId, title, text, iconName); + }); + connect(m_spotlight, &Spotlight::deviceDisconnected, this, + [batteryWarnings](const DeviceId&, const QString& name) { + batteryWarnings->remove(name); }); } +// ------------------------------------------------------------------------------------------------- +void ProjecteurApplication::showAbout() +{ + if (!m_aboutDialog) { + m_aboutDialog = new KAboutApplicationDialog(KAboutData::applicationData()); + m_aboutDialog->setAttribute(Qt::WA_DeleteOnClose); + } + + showAndActivate(m_aboutDialog); +} + +// ------------------------------------------------------------------------------------------------- +void ProjecteurApplication::showAndActivate(QWidget* widget) +{ + if (!widget) { return; } + widget->show(); + widget->raise(); + if (auto* window = widget->windowHandle()) { + KWindowSystem::updateStartupId(window); + KWindowSystem::activateWindow(window); + } +} + // ------------------------------------------------------------------------------------------------- QWindow* ProjecteurApplication::createOverlayWindow() { QObject *object = m_windowQmlComponent->create(); object->setParent(m_qmlEngine); const auto window = qobject_cast(object); - window->setFlags(window->flags() | Qt::WindowTransparentForInput | Qt::Tool); + auto layerWindow = LayerShellQt::Window::get(window); + layerWindow->setScope(QStringLiteral("projecteur-overlay")); + layerWindow->setLayer(LayerShellQt::Window::LayerOverlay); + auto anchors = LayerShellQt::Window::Anchors{LayerShellQt::Window::AnchorTop}; + anchors.setFlag(LayerShellQt::Window::AnchorBottom); + anchors.setFlag(LayerShellQt::Window::AnchorLeft); + anchors.setFlag(LayerShellQt::Window::AnchorRight); + layerWindow->setAnchors(anchors); + layerWindow->setExclusiveZone(0); + layerWindow->setKeyboardInteractivity(LayerShellQt::Window::KeyboardInteractivityNone); + layerWindow->setActivateOnShow(false); + layerWindow->setCloseOnDismissed(false); return window; } @@ -390,31 +501,19 @@ void ProjecteurApplication::updateOverlayWindow(QWindow* window, QScreen* screen window->setProperty("screenId", quint64(screen)); - const bool wasVisible = window->isVisible(); const bool wasSpotActive = m_spotlight->spotActive(); m_overlayVisible = false; emit overlayVisibleChanged(false); - window->setFlags(window->flags() | Qt::WindowTransparentForInput); - window->setFlags(window->flags() & ~Qt::WindowStaysOnTopHint); window->hide(); - window->setGeometry(QRect(screen->geometry().topLeft(), QSize(300,200))); + auto layerWindow = LayerShellQt::Window::get(window); + layerWindow->setScreen(screen); + layerWindow->setDesiredSize(QSize(0, 0)); window->setScreen(screen); - window->setGeometry(screen->geometry()); - - if (m_xcbOnWayland && !wasVisible) - { - if (m_dialog->mode() == PreferencesDialog::Mode::MinimizeOnlyDialog - && m_dialog->isMinimized()) { // keep Window minimized... - //Workaround for QTBUG-76354 (https://bugreports.qt.io/browse/QTBUG-76354) - m_dialog->showNormal(); - m_dialog->setWindowState(Qt::WindowMinimized); - } - } - if (wasVisible && wasSpotActive) { + if (wasSpotActive) { QTimer::singleShot(0, this, [this](){ if (m_spotlight->spotActive()) { emit m_spotlight->spotActiveChanged(true); @@ -434,16 +533,7 @@ void ProjecteurApplication::setScreenForCursorPos() // ------------------------------------------------------------------------------------------------- QScreen* ProjecteurApplication::screenAtCursorPos() const { -#if (QT_VERSION >= QT_VERSION_CHECK(5, 10, 0)) return this->screenAt(QCursor::pos()); -#else - const int screenNumber = this->desktop()->screenNumber(QCursor::pos()); - const auto screenList = screens(); - if (screenNumber >= 0 && screenNumber < screenList.size()) { - return screenList[screenNumber]; - } - return nullptr; -#endif } // ------------------------------------------------------------------------------------------------- @@ -550,52 +640,38 @@ void ProjecteurApplication::setCurrentCursorPos(const QPoint& pos) } // ------------------------------------------------------------------------------------------------- -void ProjecteurApplication::readCommand(QLocalSocket* clientConnection) +void ProjecteurApplication::activate() { - auto it = m_commandConnections.find(clientConnection); - if (it == m_commandConnections.end()) { - return; + if (m_dialog) { + showPreferences(true); } +} - quint32& commandSize = it->second; - - // Read size of command (always quint32) if not already done. - if (commandSize == 0) { - if (clientConnection->bytesAvailable() < static_cast(sizeof(quint32))) { - return; - } - - QDataStream in(clientConnection); - in >> commandSize; - - if (commandSize > 256) - { - logWarning(cmdserver) << tr("Received invalid command size (%1)").arg(commandSize); - clientConnection->disconnectFromServer(); - return ; +// ------------------------------------------------------------------------------------------------- +void ProjecteurApplication::applyCommands(const QStringList& commands) +{ + for (const auto& command : commands) { + const auto trimmedCommand = command.trimmed(); + if (!trimmedCommand.isEmpty()) { + applyCommand(trimmedCommand); } } +} - if (clientConnection->bytesAvailable() < commandSize || clientConnection->atEnd()) { - return; - } - - const auto command = QString::fromLocal8Bit(clientConnection->read(commandSize)); +// ------------------------------------------------------------------------------------------------- +void ProjecteurApplication::applyCommand(const QString& command) +{ const QString cmdKey = command.section('=', 0, 0).trimmed(); const QString cmdValue = command.section('=', 1).trimmed(); if (cmdKey == "quit") { - logDebug(cmdserver) << tr("Received quit command."); + qCDebug(PROJECTEUR_COMMAND_LOG).noquote() << QStringLiteral("Received quit command."); this->quit(); } else if (cmdKey == "vibrate") // with args intensity (0-255), length (0-10) { - #if (QT_VERSION >= QT_VERSION_CHECK(5, 14, 0)) - auto const args = cmdValue.split(QLatin1Char(','), Qt::SkipEmptyParts); - #else - auto const args = cmdValue.split(QLatin1Char(','), QString::SkipEmptyParts); - #endif + auto const args = cmdValue.split(QLatin1Char(','), Qt::SkipEmptyParts); std::uint8_t const intensity = [&args]{ if (args.size() >= 1) { @@ -619,9 +695,7 @@ void ProjecteurApplication::readCommand(QLocalSocket* clientConnection) return std::uint8_t{0}; }(); - logDebug(cmdserver) << tr("Received command vibrate = intensity:%1, length:%2") - .arg(intensity) - .arg(length); + qCDebug(PROJECTEUR_COMMAND_LOG).noquote() << QStringLiteral("Received command vibrate = intensity:%1, length:%2").arg(intensity).arg(length); m_deviceCommandHelper->sendVibrateCommand(intensity, length); } @@ -630,18 +704,16 @@ void ProjecteurApplication::readCommand(QLocalSocket* clientConnection) bool ok = false; int const sizeAdjust = cmdValue.toInt(&ok); if (ok) { - logDebug(cmdserver) << tr("Received command spot.size.adjust = %1%2") - .arg(sizeAdjust > 0 ? "+" : "") - .arg(sizeAdjust); + qCDebug(PROJECTEUR_COMMAND_LOG).noquote() << QStringLiteral("Received command spot.size.adjust = %1%2").arg(sizeAdjust > 0 ? "+" : "").arg(sizeAdjust); m_settings->setSpotSize(m_settings->spotSize() + sizeAdjust); } else { - logDebug(cmdserver) << tr("Received invalid value for command spot.size.adjust"); + qCDebug(PROJECTEUR_COMMAND_LOG).noquote() << QStringLiteral("Received invalid value for command spot.size.adjust"); } } else if (cmdKey == "spot") { if (cmdValue.isEmpty()) { - logDebug(cmdserver) << tr("Received empty command value for command spot"); + qCDebug(PROJECTEUR_COMMAND_LOG).noquote() << QStringLiteral("Received empty command value for command spot"); } else if (cmdValue.toLower() == "toggle") { m_spotlight->setSpotActive(!m_spotlight->spotActive()); } @@ -649,19 +721,19 @@ void ProjecteurApplication::readCommand(QLocalSocket* clientConnection) const bool active = (cmdValue.toLower() == "on" || cmdValue == "1" || cmdValue.toLower() == "true"); - logDebug(cmdserver) << tr("Received command spot = %1").arg(active); + qCDebug(PROJECTEUR_COMMAND_LOG).noquote() << QStringLiteral("Received command spot = %1").arg(active); m_spotlight->setSpotActive(active); } } else if (cmdKey == "settings" || cmdKey == "preferences") { const bool show = !(cmdValue.toLower() == "hide" || cmdValue == "0"); - logDebug(cmdserver) << tr("Received command settings = %1").arg(show); + qCDebug(PROJECTEUR_COMMAND_LOG).noquote() << QStringLiteral("Received command settings = %1").arg(show); showPreferences(show); } else if (cmdKey == "preset") { - logDebug(cmdserver) << tr("Received command preset = %1").arg(cmdValue); + qCDebug(PROJECTEUR_COMMAND_LOG).noquote() << QStringLiteral("Received command preset = %1").arg(cmdValue); if (!cmdValue.isEmpty()) { m_settings->loadPreset(cmdValue); } } else if (cmdValue.size()) @@ -672,16 +744,14 @@ void ProjecteurApplication::readCommand(QLocalSocket* clientConnection) return (pair.first == cmdKey); }); if (it != m_settings->stringProperties().cend()) { - logDebug(cmdserver) << tr("Received command '%1'='%2'").arg(cmdKey, cmdValue); + qCDebug(PROJECTEUR_COMMAND_LOG).noquote() << QStringLiteral("Received command '%1'='%2'").arg(cmdKey).arg(cmdValue); it->second.setFunction(cmdValue); } else { // string property not found... - logWarning(cmdserver) << tr("Received unknown command key (%1)").arg(cmdKey); + qCWarning(PROJECTEUR_COMMAND_LOG).noquote() << QStringLiteral("Received unknown command key (%1)").arg(cmdKey); } } - // reset command size, for next command - commandSize = 0; } // ------------------------------------------------------------------------------------------------- @@ -689,75 +759,9 @@ void ProjecteurApplication::showPreferences(bool show) { if (show) { - m_dialog->show(); - m_dialog->raise(); - static const bool qtPlatformIsWayland = QGuiApplication::platformName().toLower().startsWith("wayland"); - if (!qtPlatformIsWayland) { m_dialog->activateWindow(); } + showAndActivate(m_dialog.get()); } else { - if (m_dialog->mode() == PreferencesDialog::Mode::MinimizeOnlyDialog) { - m_dialog->showMinimized(); - } else { - m_dialog->hide(); - } - } -} - -// ================================================================================================= -ProjecteurCommandClientApp::ProjecteurCommandClientApp(const QStringList& ipcCommands, int &argc, char **argv) - : QCoreApplication(argc, argv) -{ - if (ipcCommands.isEmpty()) - { - QMetaObject::invokeMethod(this, "quit", Qt::QueuedConnection); - return; + m_dialog->reject(); } - - QLocalSocket* const localSocket = new QLocalSocket(this); - - auto socketErrorFunc = [this, localSocket](QLocalSocket::LocalSocketError /*socketError*/) { - logError(cmdclient) << tr("Error sending commands: %1", "%1=error message") - .arg(localSocket->errorString()); - localSocket->close(); - QMetaObject::invokeMethod(this, "quit", Qt::QueuedConnection); - }; - - #if (QT_VERSION >= QT_VERSION_CHECK(5, 15, 0)) - connect(localSocket, &QLocalSocket::errorOccurred, this, std::move(socketErrorFunc)); - #else - connect(localSocket, - static_cast(&QLocalSocket::error), - this, std::move(socketErrorFunc)); - #endif - - connect(localSocket, &QLocalSocket::connected, [localSocket, &ipcCommands]() - { - for (const auto& ipcCommand : ipcCommands) - { - if (ipcCommand.isEmpty()) { continue; } - - const QByteArray commandBlock = [&ipcCommand]() - { - const QByteArray ipcBytes = ipcCommand.toLocal8Bit(); - QByteArray block; - { - QDataStream out(&block, QIODevice::WriteOnly); - out << static_cast(ipcBytes.size()); - } - block.append(ipcBytes); - return block; - }(); - - localSocket->write(commandBlock); - localSocket->flush(); - } - localSocket->disconnectFromServer(); - }); - - connect(localSocket, &QLocalSocket::disconnected, this, [this, localSocket]() { - localSocket->close(); - QMetaObject::invokeMethod(this, "quit", Qt::QueuedConnection); - }); - - localSocket->connectToServer(localServerName()); } diff --git a/src/projecteurapp.h b/src/projecteurapp.h index e3ab04ae..c7fca187 100644 --- a/src/projecteurapp.h +++ b/src/projecteurapp.h @@ -10,16 +10,16 @@ #include #include -class AboutDialog; class DeviceCommandHelper; +class KAboutApplicationDialog; +class KActionCollection; +class KDBusService; class LinuxDesktop; class PreferencesDialog; -class QLocalServer; -class QLocalSocket; -class QMenu; +class PresentationTimer; +class ProjecteurControl; class QQmlApplicationEngine; class QQmlComponent; -class QSystemTrayIcon; class Settings; class Spotlight; @@ -37,13 +37,20 @@ class ProjecteurApplication : public QApplication bool showPreferencesOnStart = false; bool dialogMinimizeOnly = false; bool disableOverlay = false; + bool hideSysTrayIcon = false; + QStringList commands; std::vector additionalDevices; }; explicit ProjecteurApplication(int &argc, char **argv, const Options& options); virtual ~ProjecteurApplication() override; + KDBusService* dbusService() const { return m_dbusService; } + bool isPrimaryInstance() const { return m_primaryInstance; } + int startupExitCode() const { return m_startupExitCode; } bool overlayVisible() const { return m_overlayVisible; } + void activate(); + void applyCommands(const QStringList& commands); signals: void overlayVisibleChanged(bool visible); @@ -56,11 +63,13 @@ public slots: void spotlightWindowClicked(); void cursorPositionChanged(const QPoint& pos); -private slots: - void readCommand(QLocalSocket* client); - private: + friend class ProjecteurControl; + + void applyCommand(const QString& command); + void showAndActivate(QWidget* widget); void showPreferences(bool show = true); + void showAbout(); void setScreenForCursorPos(); QScreen* screenAtCursorPos() const; QWindow* createOverlayWindow(); @@ -71,35 +80,30 @@ private slots: QPoint currentCursorPos() const; void setCurrentCursorPos(const QPoint& pos); - void setupTrayIcon(); + void setupControlService(Options const& options); + void setupGlobalShortcuts(); + void setupNotifications(); void setupSpotlight(); private: - std::unique_ptr m_trayIcon; - std::unique_ptr m_trayMenu; std::unique_ptr m_dialog; - QPointer m_aboutDialog; - QLocalServer* const m_localServer = nullptr; + QPointer m_aboutDialog; + KActionCollection* m_actionCollection = nullptr; + KDBusService* m_dbusService = nullptr; + ProjecteurControl* m_control = nullptr; Settings* m_settings = nullptr; Spotlight* m_spotlight = nullptr; DeviceCommandHelper* m_deviceCommandHelper = nullptr; + PresentationTimer* m_presentationTimer = nullptr; LinuxDesktop* m_linuxDesktop = nullptr; QQmlApplicationEngine* m_qmlEngine = nullptr; QQmlComponent* m_windowQmlComponent = nullptr; - std::map m_commandConnections; + bool m_primaryInstance = false; + int m_startupExitCode = 0; bool m_overlayVisible = false; - const bool m_xcbOnWayland = false; QList m_overlayWindows; std::map m_screenWindowMap; quint64 m_currentSpotScreen = 0; QPoint m_currentCursorPos; }; - -class ProjecteurCommandClientApp : public QCoreApplication -{ - Q_OBJECT - -public: - explicit ProjecteurCommandClientApp(const QStringList& ipcCommands, int &argc, char **argv); -}; diff --git a/src/projecteurconfig.kcfg b/src/projecteurconfig.kcfg new file mode 100644 index 00000000..008430ab --- /dev/null +++ b/src/projecteurconfig.kcfg @@ -0,0 +1,90 @@ + + + + + + true + + + 32 + 5 + 100 + + + false + + + 5 + 3 + 100 + + + #ff0000 + + + 0.8 + 0.0 + 1.0 + + + #222222 + + + 0.3 + 0.0 + 1.0 + + + 10 + + + spotshapes/Circle.qml + + + 0.0 + 0.0 + 360.0 + + + true + + + #73d216 + + + 4 + 0 + 100 + + + 0.8 + 0.0 + 1.0 + + + false + + + 2.0 + 1.5 + 20.0 + + + smooth + + + false + + + false + + + 900 + 1 + + + diff --git a/src/projecteurcontrol.cc b/src/projecteurcontrol.cc new file mode 100644 index 00000000..eb540f75 --- /dev/null +++ b/src/projecteurcontrol.cc @@ -0,0 +1,442 @@ +// This file is part of Projecteur - https://github.com/jahnf/projecteur +// - See LICENSE.md and README.md + +#include "projecteurcontrol.h" + +#include "device-hidpp.h" +#include "presentationtimer.h" +#include "projecteurapp.h" +#include "projecteurcontroladaptor.h" +#include "settings.h" +#include "spotlight.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace { +QString batteryStatusName(HIDPP::BatteryStatus status) +{ + using BatteryStatus = HIDPP::BatteryStatus; + switch (status) { + case BatteryStatus::Discharging: return QStringLiteral("discharging"); + case BatteryStatus::Charging: return QStringLiteral("charging"); + case BatteryStatus::AlmostFull: return QStringLiteral("almost-full"); + case BatteryStatus::Full: return QStringLiteral("full"); + case BatteryStatus::SlowCharging: return QStringLiteral("slow-charging"); + case BatteryStatus::InvalidBattery: return QStringLiteral("invalid-battery"); + case BatteryStatus::ThermalError: return QStringLiteral("thermal-error"); + case BatteryStatus::ChargingError: return QStringLiteral("charging-error"); + case BatteryStatus::Uninitialized: return {}; + } + return {}; +} +} + +ProjecteurControl::ProjecteurControl(ProjecteurApplication* application, Settings* settings, + Spotlight* spotlight, PresentationTimer* presentationTimer, + bool trayVisible) + : QObject(application) + , m_application(application) + , m_settings(settings) + , m_spotlight(spotlight) + , m_presentationTimer(presentationTimer) + , m_trayVisible(trayVisible) +{ + new ProjecteurControlAdaptor(this); + + connect(m_settings, &Settings::overlayDisabledChanged, this, [this](bool disabled) { + const bool enabled = !disabled; + emit overlayEnabledChanged(enabled); + emitPropertiesChanged({{QStringLiteral("OverlayEnabled"), enabled}}); + }); + connect(m_spotlight, &Spotlight::spotActiveChanged, this, [this](bool active) { + emit spotlightActiveChanged(active); + emitPropertiesChanged({{QStringLiteral("SpotlightActive"), active}}); + }); + connect(m_presentationTimer, &PresentationTimer::enabledChanged, this, + [this](bool enabled) { + emit timerEnabledChanged(enabled); + emitPropertiesChanged({{QStringLiteral("TimerEnabled"), enabled}}); + }); + connect(m_presentationTimer, &PresentationTimer::stateChanged, this, [this]() { + const auto state = timerState(); + emit timerStateChanged(state); + emitPropertiesChanged({{QStringLiteral("TimerState"), state}}); + }); + connect(m_presentationTimer, &PresentationTimer::durationSecondsChanged, this, + [this](int seconds) { + emit timerDurationSecondsChanged(seconds); + emitPropertiesChanged({{QStringLiteral("TimerDurationSeconds"), seconds}}); + }); + connect(m_presentationTimer, &PresentationTimer::remainingSecondsChanged, this, + [this](int seconds) { + emit timerRemainingSecondsChanged(seconds); + emitPropertiesChanged({{QStringLiteral("TimerRemainingSeconds"), seconds}}); + }); + const auto updateConnectedDevices = [this]() { + const auto devices = connectedDevices(); + emit connectedDevicesChanged(devices); + emitPropertiesChanged({{QStringLiteral("ConnectedDevices"), devices}}); + emitBatteryPropertiesChanged(); + }; + connect(m_spotlight, &Spotlight::deviceConnected, this, updateConnectedDevices); + connect(m_spotlight, &Spotlight::deviceDisconnected, this, updateConnectedDevices); + connect(m_spotlight, &Spotlight::subDeviceConnected, this, + [this](const DeviceId& id, const QString& /* name */, const QString& path) { + watchBatteryConnection(id, path); + }); + + for (const auto& device : m_spotlight->connectedDevices()) + { + const auto connection = m_spotlight->deviceConnection(device.id); + if (!connection) { continue; } + for (const auto& subDevice : connection->subDevices()) { + watchBatteryConnection(device.id, subDevice.first); + } + } + requestBatteryUpdates(); + + auto* batteryTimer = new QTimer(this); + batteryTimer->setTimerType(Qt::VeryCoarseTimer); + batteryTimer->setInterval(5 * 60 * 1000); + connect(batteryTimer, &QTimer::timeout, this, &ProjecteurControl::requestBatteryUpdates); + batteryTimer->start(); + + const auto updatePresets = [this]() { + if (!m_currentPreset.isEmpty() && !m_settings->presetModel()->hasPreset(m_currentPreset)) { + clearCurrentPreset(); + } + const auto presetNames = presets(); + emit presetsChanged(presetNames); + emitPropertiesChanged({{QStringLiteral("Presets"), presetNames}}); + }; + connect(m_settings->presetModel(), &QAbstractItemModel::rowsInserted, this, updatePresets); + connect(m_settings->presetModel(), &QAbstractItemModel::rowsRemoved, this, updatePresets); + connect(m_settings->presetModel(), &QAbstractItemModel::modelReset, this, updatePresets); + + const auto settingsChanged = [this]() { clearCurrentPreset(); }; + connect(m_settings, &Settings::showSpotShadeChanged, this, settingsChanged); + connect(m_settings, &Settings::spotSizeChanged, this, settingsChanged); + connect(m_settings, &Settings::showCenterDotChanged, this, settingsChanged); + connect(m_settings, &Settings::dotSizeChanged, this, settingsChanged); + connect(m_settings, &Settings::dotColorChanged, this, settingsChanged); + connect(m_settings, &Settings::dotOpacityChanged, this, settingsChanged); + connect(m_settings, &Settings::shadeColorChanged, this, settingsChanged); + connect(m_settings, &Settings::shadeOpacityChanged, this, settingsChanged); + connect(m_settings, &Settings::cursorChanged, this, settingsChanged); + connect(m_settings, &Settings::spotShapeChanged, this, settingsChanged); + connect(m_settings, &Settings::spotRotationChanged, this, settingsChanged); + connect(m_settings, &Settings::showBorderChanged, this, settingsChanged); + connect(m_settings, &Settings::borderColorChanged, this, settingsChanged); + connect(m_settings, &Settings::borderSizeChanged, this, settingsChanged); + connect(m_settings, &Settings::borderOpacityChanged, this, settingsChanged); + connect(m_settings, &Settings::zoomEnabledChanged, this, settingsChanged); + connect(m_settings, &Settings::zoomFactorChanged, this, settingsChanged); + connect(m_settings, &Settings::zoomModeChanged, this, settingsChanged); + connect(m_settings, &Settings::multiScreenOverlayEnabledChanged, this, settingsChanged); + + for (const auto& shape : Settings::spotShapes()) { + if (auto* shapeSettings = m_settings->shapeSettings(shape.name())) { + connect(shapeSettings, &QQmlPropertyMap::valueChanged, this, settingsChanged); + } + } + + connect(m_settings, &Settings::presetLoaded, this, [this](const QString& preset) { + if (m_currentPreset == preset) { return; } + m_currentPreset = preset; + emit currentPresetChanged(m_currentPreset); + emitPropertiesChanged({{QStringLiteral("CurrentPreset"), m_currentPreset}}); + }); +} + +bool ProjecteurControl::registerObject() +{ + auto connection = QDBusConnection::sessionBus(); + m_objectRegistered = connection.registerObject( + QString::fromLatin1(ObjectPath), this, + QDBusConnection::ExportAdaptors); + return m_objectRegistered; +} + +void ProjecteurControl::unregisterObject() +{ + auto connection = QDBusConnection::sessionBus(); + if (m_objectRegistered) { + connection.unregisterObject(QString::fromLatin1(ObjectPath)); + m_objectRegistered = false; + } +} + +bool ProjecteurControl::overlayEnabled() const +{ + return !m_settings->overlayDisabled(); +} + +bool ProjecteurControl::spotlightActive() const +{ + return m_spotlight->spotActive(); +} + +QStringList ProjecteurControl::connectedDevices() const +{ + QStringList result; + for (const auto& device : m_spotlight->connectedDevices()) { + result.push_back(device.name); + } + return result; +} + +QList ProjecteurControl::connectedDeviceBatteryLevels() const +{ + QList result; + for (const auto& device : m_spotlight->connectedDevices()) + { + int level = -1; + const auto connection = m_spotlight->deviceConnection(device.id); + if (connection) + { + for (const auto& subDevice : connection->subDevices()) + { + const auto hidpp = qobject_cast(subDevice.second.get()); + if (hidpp && hidpp->hasFlags(DeviceFlag::ReportBattery) + && hidpp->batteryInfo().status != HIDPP::BatteryStatus::Uninitialized) + { + level = hidpp->batteryInfo().currentLevel; + break; + } + } + } + result.push_back(level); + } + return result; +} + +QStringList ProjecteurControl::connectedDeviceBatteryStatuses() const +{ + QStringList result; + for (const auto& device : m_spotlight->connectedDevices()) + { + QString status; + const auto connection = m_spotlight->deviceConnection(device.id); + if (connection) + { + for (const auto& subDevice : connection->subDevices()) + { + const auto hidpp = qobject_cast(subDevice.second.get()); + if (hidpp && hidpp->hasFlags(DeviceFlag::ReportBattery)) + { + status = batteryStatusName(hidpp->batteryInfo().status); + if (!status.isEmpty()) { break; } + } + } + } + result.push_back(status); + } + return result; +} + +QStringList ProjecteurControl::presets() const +{ + QStringList result; + for (const auto& preset : m_settings->presets()) { + result.push_back(preset); + } + return result; +} + +bool ProjecteurControl::timerEnabled() const +{ + return m_presentationTimer->enabled(); +} + +QString ProjecteurControl::timerState() const +{ + return m_presentationTimer->stateName(); +} + +int ProjecteurControl::timerDurationSeconds() const +{ + return m_presentationTimer->durationSeconds(); +} + +int ProjecteurControl::timerRemainingSeconds() const +{ + return m_presentationTimer->remainingSeconds(); +} + +void ProjecteurControl::SetOverlayEnabled(bool enabled) +{ + m_settings->setOverlayDisabled(!enabled); +} + +void ProjecteurControl::SetSpotlightActive(bool active) +{ + m_spotlight->setSpotActive(active); +} + +bool ProjecteurControl::LoadPreset(const QString& preset) +{ + if (!m_settings->presetModel()->hasPreset(preset)) { return false; } + m_settings->loadPreset(preset); + return true; +} + +void ProjecteurControl::SetTimerEnabled(bool enabled) +{ + m_presentationTimer->setEnabled(enabled); +} + +void ProjecteurControl::StartTimer() +{ + m_presentationTimer->start(); +} + +void ProjecteurControl::RestartTimer() +{ + m_presentationTimer->restart(); +} + +void ProjecteurControl::ResetTimer() +{ + m_presentationTimer->reset(); +} + +void ProjecteurControl::SetTimerDurationSeconds(int seconds) +{ + m_presentationTimer->setDurationSeconds(seconds); +} + +void ProjecteurControl::loadNextPreset() +{ + loadRelativePreset(1); +} + +void ProjecteurControl::loadPreviousPreset() +{ + loadRelativePreset(-1); +} + +void ProjecteurControl::ShowPreferences() +{ + m_application->showPreferences(true); +} + +void ProjecteurControl::ShowAbout() +{ + m_application->showAbout(); +} + +void ProjecteurControl::ApplyCommands(const QStringList& commands) +{ + m_application->applyCommands(commands); +} + +void ProjecteurControl::Quit() +{ + QCoreApplication::quit(); +} + +void ProjecteurControl::loadRelativePreset(int offset) +{ + const auto presetNames = presets(); + if (presetNames.isEmpty()) { return; } + + const auto currentIndex = presetNames.indexOf(m_currentPreset); + qsizetype targetIndex = 0; + if (offset < 0) { + targetIndex = currentIndex <= 0 ? presetNames.size() - 1 : currentIndex - 1; + } else { + targetIndex = currentIndex < 0 || currentIndex == presetNames.size() - 1 + ? 0 + : currentIndex + 1; + } + LoadPreset(presetNames.at(targetIndex)); +} + +void ProjecteurControl::clearCurrentPreset() +{ + if (m_currentPreset.isEmpty()) { return; } + m_currentPreset.clear(); + emit currentPresetChanged(m_currentPreset); + emitPropertiesChanged({{QStringLiteral("CurrentPreset"), m_currentPreset}}); +} + +void ProjecteurControl::emitBatteryPropertiesChanged() +{ + const auto levels = connectedDeviceBatteryLevels(); + const auto statuses = connectedDeviceBatteryStatuses(); + emit connectedDeviceBatteryLevelsChanged(levels); + emit connectedDeviceBatteryStatusesChanged(statuses); + emitPropertiesChanged({ + {QStringLiteral("ConnectedDeviceBatteryLevels"), QVariant::fromValue(levels)}, + {QStringLiteral("ConnectedDeviceBatteryStatuses"), statuses} + }); +} + +void ProjecteurControl::requestBatteryUpdates() +{ + for (const auto& device : m_spotlight->connectedDevices()) + { + const auto connection = m_spotlight->deviceConnection(device.id); + if (!connection) { continue; } + for (const auto& subDevice : connection->subDevices()) + { + const auto hidpp = qobject_cast(subDevice.second.get()); + if (hidpp && hidpp->hasFlags(DeviceFlag::ReportBattery)) { + hidpp->triggerBattyerInfoUpdate(); + } + } + } +} + +void ProjecteurControl::watchBatteryConnection(const DeviceId& id, const QString& path) +{ + const auto connection = m_spotlight->deviceConnection(id); + if (!connection) { return; } + const auto deviceName = connection->deviceName(); + const auto subDevice = connection->subDevice(path); + const auto hidpp = qobject_cast(subDevice.get()); + if (!hidpp) { return; } + if (m_watchedBatteryConnections.contains(hidpp)) { return; } + + m_watchedBatteryConnections.insert(hidpp); + connect(hidpp, &QObject::destroyed, this, [this, hidpp]() { + m_watchedBatteryConnections.remove(hidpp); + }); + + connect(hidpp, &SubHidppConnection::batteryInfoChanged, this, + [this, deviceName](const HIDPP::BatteryInfo& info) { + emitBatteryPropertiesChanged(); + emit batteryStateChanged(deviceName, info.currentLevel, batteryStatusName(info.status)); + }); + connect(hidpp, &SubHidppConnection::featureSetInitialized, this, + [this, hidpp, deviceName]() { + emitBatteryPropertiesChanged(); + const auto& info = hidpp->batteryInfo(); + if (info.status != HIDPP::BatteryStatus::Uninitialized) { + emit batteryStateChanged(deviceName, info.currentLevel, batteryStatusName(info.status)); + } + if (hidpp->hasFlags(DeviceFlag::ReportBattery)) { + hidpp->triggerBattyerInfoUpdate(); + } + }); + + if (hidpp->hasFlags(DeviceFlag::ReportBattery)) { + hidpp->triggerBattyerInfoUpdate(); + } +} + +void ProjecteurControl::emitPropertiesChanged(const QVariantMap& changedProperties) +{ + if (!m_objectRegistered) { return; } + auto message = QDBusMessage::createSignal( + QString::fromLatin1(ObjectPath), QStringLiteral("org.freedesktop.DBus.Properties"), + QStringLiteral("PropertiesChanged")); + message << QString::fromLatin1(InterfaceName) << changedProperties << QStringList{}; + QDBusConnection::sessionBus().send(message); +} diff --git a/src/projecteurcontrol.h b/src/projecteurcontrol.h new file mode 100644 index 00000000..e130b447 --- /dev/null +++ b/src/projecteurcontrol.h @@ -0,0 +1,107 @@ +// This file is part of Projecteur - https://github.com/jahnf/projecteur +// - See LICENSE.md and README.md +#pragma once + +#include +#include +#include +#include +#include + +class ProjecteurApplication; +class PresentationTimer; +class Settings; +class Spotlight; +class SubHidppConnection; +struct DeviceId; + +class ProjecteurControl : public QObject +{ + Q_OBJECT + Q_CLASSINFO("D-Bus Interface", "org.projecteur.Projecteur") + Q_PROPERTY(bool TrayVisible READ trayVisible CONSTANT) + Q_PROPERTY(bool OverlayEnabled READ overlayEnabled NOTIFY overlayEnabledChanged) + Q_PROPERTY(bool SpotlightActive READ spotlightActive NOTIFY spotlightActiveChanged) + Q_PROPERTY(QStringList ConnectedDevices READ connectedDevices NOTIFY connectedDevicesChanged) + Q_PROPERTY(QList ConnectedDeviceBatteryLevels READ connectedDeviceBatteryLevels + NOTIFY connectedDeviceBatteryLevelsChanged) + Q_PROPERTY(QStringList ConnectedDeviceBatteryStatuses READ connectedDeviceBatteryStatuses + NOTIFY connectedDeviceBatteryStatusesChanged) + Q_PROPERTY(QStringList Presets READ presets NOTIFY presetsChanged) + Q_PROPERTY(QString CurrentPreset READ currentPreset NOTIFY currentPresetChanged) + Q_PROPERTY(bool TimerEnabled READ timerEnabled NOTIFY timerEnabledChanged) + Q_PROPERTY(QString TimerState READ timerState NOTIFY timerStateChanged) + Q_PROPERTY(int TimerDurationSeconds READ timerDurationSeconds NOTIFY timerDurationSecondsChanged) + Q_PROPERTY(int TimerRemainingSeconds READ timerRemainingSeconds NOTIFY timerRemainingSecondsChanged) + +public: + static constexpr auto ServiceName = "org.projecteur.Projecteur"; + static constexpr auto ObjectPath = "/org/projecteur/Projecteur/Control"; + static constexpr auto InterfaceName = "org.projecteur.Projecteur"; + + ProjecteurControl(ProjecteurApplication* application, Settings* settings, Spotlight* spotlight, + PresentationTimer* presentationTimer, bool trayVisible); + + bool registerObject(); + void unregisterObject(); + + bool trayVisible() const { return m_trayVisible; } + bool overlayEnabled() const; + bool spotlightActive() const; + QStringList connectedDevices() const; + QList connectedDeviceBatteryLevels() const; + QStringList connectedDeviceBatteryStatuses() const; + QStringList presets() const; + QString currentPreset() const { return m_currentPreset; } + bool timerEnabled() const; + QString timerState() const; + int timerDurationSeconds() const; + int timerRemainingSeconds() const; + void loadNextPreset(); + void loadPreviousPreset(); + +public slots: + void SetOverlayEnabled(bool enabled); + void SetSpotlightActive(bool active); + bool LoadPreset(const QString& preset); + void SetTimerEnabled(bool enabled); + void StartTimer(); + void RestartTimer(); + void ResetTimer(); + void SetTimerDurationSeconds(int seconds); + void ShowPreferences(); + void ShowAbout(); + void ApplyCommands(const QStringList& commands); + void Quit(); + +signals: + void overlayEnabledChanged(bool enabled); + void spotlightActiveChanged(bool active); + void connectedDevicesChanged(const QStringList& devices); + void connectedDeviceBatteryLevelsChanged(const QList& levels); + void connectedDeviceBatteryStatusesChanged(const QStringList& statuses); + void presetsChanged(const QStringList& presets); + void currentPresetChanged(const QString& preset); + void timerEnabledChanged(bool enabled); + void timerStateChanged(const QString& state); + void timerDurationSecondsChanged(int seconds); + void timerRemainingSecondsChanged(int seconds); + void batteryStateChanged(const QString& deviceName, int level, const QString& status); + +private: + void loadRelativePreset(int offset); + void clearCurrentPreset(); + void emitBatteryPropertiesChanged(); + void requestBatteryUpdates(); + void watchBatteryConnection(const DeviceId& id, const QString& path); + void emitPropertiesChanged(const QVariantMap& changedProperties); + + ProjecteurApplication* const m_application; + Settings* const m_settings; + Spotlight* const m_spotlight; + PresentationTimer* const m_presentationTimer; + const bool m_trayVisible; + QString m_currentPreset; + QSet m_watchedBatteryConnections; + bool m_objectRegistered = false; +}; diff --git a/src/runguard.cc b/src/runguard.cc deleted file mode 100644 index aecd4b70..00000000 --- a/src/runguard.cc +++ /dev/null @@ -1,71 +0,0 @@ -#include "runguard.h" - -#include - - -namespace { - QString generateKeyHash(const QString& key, const QString& salt) - { - const QByteArray data(key.toUtf8().append(salt.toUtf8())); - return QCryptographicHash::hash(data, QCryptographicHash::Sha1).toHex(); - } -} - -RunGuard::RunGuard(const QString& key) - : m_key(key) - , m_memLockKey(generateKeyHash(key, "_memLockKey")) - , m_sharedmemKey(generateKeyHash(key, "_sharedmemKey")) - , m_sharedMem(m_sharedmemKey) - , m_memLock(m_memLockKey, 1) -{ - m_memLock.acquire(); - { - QSharedMemory fix(m_sharedmemKey); // Fix for *nix: http://habrahabr.ru/post/173281/ - fix.attach(); - } - m_memLock.release(); -} - -RunGuard::~RunGuard() -{ - release(); -} - -bool RunGuard::isAnotherRunning() -{ - if (m_sharedMem.isAttached()) - return false; - - m_memLock.acquire(); - const bool isRunning = m_sharedMem.attach(); - if (isRunning) - m_sharedMem.detach(); - m_memLock.release(); - - return isRunning; -} - -bool RunGuard::tryToRun() -{ - if (isAnotherRunning()) // Extra check - return false; - - m_memLock.acquire(); - const bool result = m_sharedMem.create(sizeof(quint64)); - m_memLock.release(); - if (!result) - { - release(); - return false; - } - - return true; -} - -void RunGuard::release() -{ - m_memLock.acquire(); - if (m_sharedMem.isAttached()) - m_sharedMem.detach(); - m_memLock.release(); -} diff --git a/src/runguard.h b/src/runguard.h deleted file mode 100644 index 03cac1bf..00000000 --- a/src/runguard.h +++ /dev/null @@ -1,25 +0,0 @@ -#pragma once - -#include -#include - -class RunGuard -{ -public: - explicit RunGuard(const QString& key); - ~RunGuard(); - - bool isAnotherRunning(); - bool tryToRun(); - void release(); - -private: - const QString m_key; - const QString m_memLockKey; - const QString m_sharedmemKey; - - QSharedMemory m_sharedMem; - QSystemSemaphore m_memLock; - - Q_DISABLE_COPY(RunGuard) -}; diff --git a/src/settings.cc b/src/settings.cc index efa41dbe..e1f746cd 100644 --- a/src/settings.cc +++ b/src/settings.cc @@ -5,21 +5,32 @@ #include "device.h" #include "deviceinput.h" -#include "logging.h" +#include "projecteurconfig.h" +#include "projecteur_settings_debug.h" #include #include +#include +#include +#include + #include #include #include #include #include -#include - -LOGGING_CATEGORY(lcSettings, "settings") namespace { + QQmlPropertyMap* createQmlPropertyMap(QObject* parent) + { +#if QT_VERSION >= QT_VERSION_CHECK(6, 11, 0) + return QQmlPropertyMap::create(parent); +#else + return new QQmlPropertyMap(parent); +#endif + } + // ----------------------------------------------------------------------------------------------- namespace settings { constexpr char showSpotShade[] = "showSpotShade"; @@ -39,15 +50,14 @@ namespace { constexpr char borderOpacity[] = "borderOpacity"; constexpr char zoomEnabled[] = "enableZoom"; constexpr char zoomFactor[] = "zoomFactor"; + constexpr char zoomMode[] = "zoomMode"; constexpr char multiScreenOverlay[] = "multiScreenOverlay"; + constexpr char presentationTimerEnabled[] = "presentationTimerEnabled"; + constexpr char presentationTimerDurationSeconds[] = "presentationTimerDurationSeconds"; // -- device specific constexpr char inputSequenceInterval[] = "inputSequenceInterval"; - constexpr char inputMapConfig[] = "inputMapConfig"; - constexpr char timerEnabled[] = "timer%1enabled"; - constexpr char timerSeconds[] = "timer%1seconds"; - constexpr char vibrationLength[] = "vibrationLength"; - constexpr char vibrationIntensity[] = "vibrationIntensity"; + constexpr char presentationTimerHapticStrength[] = "presentationTimerHapticStrength"; namespace defaultValue { constexpr bool showSpotShade = true; @@ -67,12 +77,14 @@ namespace { constexpr double borderOpacity = 0.8; constexpr bool zoomEnabled = false; constexpr double zoomFactor = 2.0; + constexpr char zoomMode[] = "smooth"; constexpr bool multiScreenOverlay = false; + constexpr bool presentationTimerEnabled = false; + constexpr int presentationTimerDurationSeconds = 15 * 60; // -- device specific defaults constexpr int inputSequenceInterval = 250; - constexpr uint8_t vibrationLength = 0; - constexpr uint8_t vibrationIntensity = 128; + constexpr int presentationTimerHapticStrength = 50; } // end namespace defaultValue namespace ranges { @@ -94,6 +106,13 @@ namespace { return (value.toLower() == "true" || value.toLower() == "on" || value.toInt() > 0); } + // ----------------------------------------------------------------------------------------------- + bool isZoomMode(const QString& mode) { + return mode == QStringLiteral("smooth") + || mode == QStringLiteral("text") + || mode == QStringLiteral("pixel"); + } + // ----------------------------------------------------------------------------------------------- #define SETTINGS_PRESET_PREFIX "Preset_" QString presetSection(const QString& preset, bool withSeparator = true) { @@ -103,14 +122,55 @@ namespace { // ----------------------------------------------------------------------------------------------- QString settingsKey(const DeviceId& dId, const QString& key) { return QString("Device_%1_%2/%3") - .arg(logging::hexId(dId.vendorId), logging::hexId(dId.productId), key); + .arg(formatHexId(dId.vendorId), formatHexId(dId.productId), key); + } + + struct ConfigEntry { + KConfigGroup group; + QString key; + }; + + ConfigEntry configEntry(KConfig* config, const QString& path) + { + auto parts = path.split(QLatin1Char('/'), Qt::SkipEmptyParts); + if (parts.size() == 1) { + return {KConfigGroup(config, QStringLiteral("General")), parts.constFirst()}; + } + + KConfigGroup group(config, parts.takeFirst()); + while (parts.size() > 1) { + group = group.group(parts.takeFirst()); + } + return {group, parts.constFirst()}; + } + + void writeConfigValue(KConfig* config, const QString& path, const QVariant& value) + { + auto entry = configEntry(config, path); + if (value.metaType().id() == QMetaType::QByteArray) { + entry.group.writeEntry(entry.key, value.toByteArray()); + } else { + entry.group.writeEntry(entry.key, value); + } + } + + constexpr quint32 inputMapFormatVersion = 1; + constexpr auto inputMapConfigDataKey = "inputMapConfigData"; + + std::unique_ptr createConfig(const QString& configFile) + { + if (configFile.isEmpty()) { + return std::make_unique( + QStringLiteral("projecteurrc"), KConfig::SimpleConfig); + } + return std::make_unique(configFile, KConfig::SimpleConfig); } // ------------------------------------------------------------------------------------------------- - auto loadPresets(QSettings* settings) + auto loadPresets(KConfig* config) { std::vector presets; - for (const auto& group: settings->childGroups()) { + for (const auto& group: config->groupList()) { if (group.startsWith(SETTINGS_PRESET_PREFIX)) { presets.emplace_back(group.mid(sizeof(SETTINGS_PRESET_PREFIX)-1)); } @@ -124,38 +184,95 @@ namespace { // ------------------------------------------------------------------------------------------------- Settings::Settings(QObject* parent) : QObject(parent) - , m_settings(new QSettings(QCoreApplication::applicationName(), - QCoreApplication::applicationName(), this)) - , m_presetModel(new PresetModel(loadPresets(m_settings), this)) - , m_shapeSettingsRoot(new QQmlPropertyMap(this)) + , m_shapeSettingsRoot(createQmlPropertyMap(this)) { + auto config = createConfig({}); + m_config = std::make_unique(std::move(config)); + m_presetModel = new PresetModel(loadPresets(m_config->config()), this); init(); } // ------------------------------------------------------------------------------------------------- Settings::Settings(const QString& configFile, QObject* parent) : QObject(parent) - , m_settings(new QSettings(configFile, QSettings::NativeFormat, this)) - , m_presetModel(new PresetModel(loadPresets(m_settings), this)) - , m_shapeSettingsRoot(new QQmlPropertyMap(this)) + , m_shapeSettingsRoot(createQmlPropertyMap(this)) { + auto config = createConfig(configFile); + m_config = std::make_unique(std::move(config)); + m_presetModel = new PresetModel(loadPresets(m_config->config()), this); init(); } // ------------------------------------------------------------------------------------------------- Settings::~Settings() = default; +// ------------------------------------------------------------------------------------------------- +QVariant Settings::readValue(const QString& path, const QVariant& defaultValue) const +{ + const auto entry = configEntry(m_config->config(), path); + return entry.group.readEntry(entry.key, defaultValue); +} + +// ------------------------------------------------------------------------------------------------- +void Settings::writeValue(const QString& path, const QVariant& value) +{ + writeConfigValue(m_config->config(), path, value); + sync(); +} + +// ------------------------------------------------------------------------------------------------- +bool Settings::contains(const QString& path) const +{ + const auto entry = configEntry(m_config->config(), path); + return entry.group.hasKey(entry.key); +} + +// ------------------------------------------------------------------------------------------------- +void Settings::remove(const QString& path) +{ + if (!path.contains(QLatin1Char('/'))) { + KConfigGroup(m_config->config(), path).deleteGroup(); + sync(); + return; + } + auto entry = configEntry(m_config->config(), path); + entry.group.deleteEntry(entry.key); + sync(); +} + +// ------------------------------------------------------------------------------------------------- +QString Settings::configFileName() const +{ + return m_config->config()->name(); +} + +// ------------------------------------------------------------------------------------------------- +void Settings::save() +{ + if (!m_config->save()) { + qCWarning(PROJECTEUR_SETTINGS_LOG).noquote() << QStringLiteral("Could not save settings to '%1'.").arg(configFileName()); + } +} + +// ------------------------------------------------------------------------------------------------- +void Settings::sync() +{ + if (!m_config->config()->sync()) { + qCWarning(PROJECTEUR_SETTINGS_LOG).noquote() << QStringLiteral("Could not save settings to '%1'.").arg(configFileName()); + } +} + // ------------------------------------------------------------------------------------------------- void Settings::init() { - const QFileInfo fi(m_settings->fileName()); + const QFileInfo fi(configFileName()); if (!fi.isReadable()) { - logError(lcSettings) << tr("Settings file '%1' not readable.").arg(m_settings->fileName()); + qCDebug(PROJECTEUR_SETTINGS_LOG).noquote() << QStringLiteral("Settings file '%1' does not exist yet.").arg(configFileName()); } - if (!fi.isWritable()) { - logWarning(lcSettings) << tr("Settings file '%1' not writable.").arg(m_settings->fileName()); + if (fi.exists() && !fi.isWritable()) { + qCWarning(PROJECTEUR_SETTINGS_LOG).noquote() << QStringLiteral("Settings file '%1' not writable.").arg(configFileName()); } shapeSettingsInitialize(); @@ -198,11 +315,7 @@ void Settings::initializeStringProperties() const auto pm = shapeSettings(shape.name()); if (!pm || !pm->property(shapeSetting.settingsKey().toLocal8Bit()).isValid()) { continue; } - #if (QT_VERSION < QT_VERSION_CHECK(6, 0, 0)) - if (shapeSetting.defaultValue().type() != QVariant::Int) { continue; } - #else if (shapeSetting.defaultValue().metaType().id() != QMetaType::Int) { continue; } - #endif const auto stringProperty = QString("spot.shape.%1.%2").arg(shape.name().toLower()) .arg(shapeSetting.settingsKey().toLower()); @@ -252,6 +365,9 @@ void Settings::initializeStringProperties() map.emplace_back( "zoom.factor", StringProperty{ StringProperty::Double, {::settings::ranges::zoomFactor.min, ::settings::ranges::zoomFactor.max}, [this](const QString& value){ setZoomFactor(value.toDouble()); } } ); + map.emplace_back( "zoom.mode", StringProperty{ StringProperty::StringEnum, + {QStringLiteral("smooth"), QStringLiteral("text"), QStringLiteral("pixel")}, + [this](const QString& value){ setZoomMode(value); } } ); } // ------------------------------------------------------------------------------------------------- @@ -275,17 +391,134 @@ const Settings::SettingRange& Settings::inputSequenceIntervalRange() { retu const QList& Settings::spotShapes() { static const QList shapes{ - SpotShape(::settings::defaultValue::spotShape, "Circle", tr("Circle"), false), - SpotShape("spotshapes/Square.qml", "Square", tr("(Rounded) Square"), true, - {SpotShapeSetting(tr("Border-radius (%)"), "radius", 20, 0, 100, 0)} ), - SpotShape("spotshapes/Star.qml", "Star", tr("Star"), true, - {SpotShapeSetting(tr("Star points"), "points", 5, 3, 100, 0), - SpotShapeSetting(tr("Inner radius (%)"), "innerRadius", 50, 5, 100, 0)} ), - SpotShape("spotshapes/Ngon.qml", "Ngon", tr("N-gon"), true, - {SpotShapeSetting(tr("Sides"), "sides", 3, 3, 100, 0)} ) }; + SpotShape(::settings::defaultValue::spotShape, "Circle", i18n("Circle"), false), + SpotShape("spotshapes/Square.qml", "Square", i18n("(Rounded) Square"), true, + {SpotShapeSetting(i18n("Border-radius (%)"), "radius", 20, 0, 100, 0)} ), + SpotShape("spotshapes/Star.qml", "Star", i18n("Star"), true, + {SpotShapeSetting(i18n("Star points"), "points", 5, 3, 100, 0), + SpotShapeSetting(i18n("Inner radius (%)"), "innerRadius", 50, 5, 100, 0)} ), + SpotShape("spotshapes/Ngon.qml", "Ngon", i18n("N-gon"), true, + {SpotShapeSetting(i18n("Sides"), "sides", 3, 3, 100, 0)} ) }; return shapes; } +// ------------------------------------------------------------------------------------------------- +Settings::SpotlightSettings Settings::spotlightSettings() const +{ + SpotlightSettings values{ + {::settings::showSpotShade, m_showSpotShade}, + {::settings::spotSize, m_spotSize}, + {::settings::showCenterDot, m_showCenterDot}, + {::settings::dotSize, m_dotSize}, + {::settings::dotColor, m_dotColor}, + {::settings::dotOpacity, m_dotOpacity}, + {::settings::shadeColor, m_shadeColor}, + {::settings::shadeOpacity, m_shadeOpacity}, + {::settings::cursor, static_cast(m_cursor)}, + {::settings::spotShape, m_spotShape}, + {::settings::spotRotation, m_spotRotation}, + {::settings::showBorder, m_showBorder}, + {::settings::borderColor, m_borderColor}, + {::settings::borderSize, m_borderSize}, + {::settings::borderOpacity, m_borderOpacity}, + {::settings::zoomEnabled, m_zoomEnabled}, + {::settings::zoomFactor, m_zoomFactor}, + {::settings::zoomMode, m_zoomMode}, + {::settings::multiScreenOverlay, m_multiScreenOverlayEnabled}, + }; + + for (const auto& shape : spotShapes()) + { + const auto propertyMap = m_shapeSettings.find(shape.name()); + if (propertyMap == m_shapeSettings.cend()) { continue; } + + for (const auto& setting : shape.shapeSettings()) { + values.insert(QString("Shape.%1/%2").arg(shape.name(), setting.settingsKey()), + propertyMap->second->property(setting.settingsKey().toLocal8Bit())); + } + } + return values; +} + +// ------------------------------------------------------------------------------------------------- +Settings::SpotlightSettings Settings::defaultSpotlightSettings() +{ + SpotlightSettings values{ + {::settings::showSpotShade, ::settings::defaultValue::showSpotShade}, + {::settings::spotSize, ::settings::defaultValue::spotSize}, + {::settings::showCenterDot, ::settings::defaultValue::showCenterDot}, + {::settings::dotSize, ::settings::defaultValue::dotSize}, + {::settings::dotColor, QColor(::settings::defaultValue::dotColor)}, + {::settings::dotOpacity, ::settings::defaultValue::dotOpacity}, + {::settings::shadeColor, QColor(::settings::defaultValue::shadeColor)}, + {::settings::shadeOpacity, ::settings::defaultValue::shadeOpacity}, + {::settings::cursor, static_cast(::settings::defaultValue::cursor)}, + {::settings::spotShape, QString(::settings::defaultValue::spotShape)}, + {::settings::spotRotation, ::settings::defaultValue::spotRotation}, + {::settings::showBorder, ::settings::defaultValue::showBorder}, + {::settings::borderColor, QColor(::settings::defaultValue::borderColor)}, + {::settings::borderSize, ::settings::defaultValue::borderSize}, + {::settings::borderOpacity, ::settings::defaultValue::borderOpacity}, + {::settings::zoomEnabled, ::settings::defaultValue::zoomEnabled}, + {::settings::zoomFactor, ::settings::defaultValue::zoomFactor}, + {::settings::zoomMode, QString(::settings::defaultValue::zoomMode)}, + {::settings::multiScreenOverlay, ::settings::defaultValue::multiScreenOverlay}, + }; + + for (const auto& shape : spotShapes()) { + for (const auto& setting : shape.shapeSettings()) { + values.insert(QString("Shape.%1/%2").arg(shape.name(), setting.settingsKey()), + setting.defaultValue()); + } + } + return values; +} + +// ------------------------------------------------------------------------------------------------- +void Settings::setSpotlightSettings(const SpotlightSettings& values) +{ + setShowSpotShade(values.value(::settings::showSpotShade, m_showSpotShade).toBool()); + setSpotSize(values.value(::settings::spotSize, m_spotSize).toInt()); + setShowCenterDot(values.value(::settings::showCenterDot, m_showCenterDot).toBool()); + setDotSize(values.value(::settings::dotSize, m_dotSize).toInt()); + setDotColor(values.value(::settings::dotColor, m_dotColor).value()); + setDotOpacity(values.value(::settings::dotOpacity, m_dotOpacity).toDouble()); + setShadeColor(values.value(::settings::shadeColor, m_shadeColor).value()); + setShadeOpacity(values.value(::settings::shadeOpacity, m_shadeOpacity).toDouble()); + setCursor(static_cast( + values.value(::settings::cursor, static_cast(m_cursor)).toInt())); + setSpotShape(values.value(::settings::spotShape, m_spotShape).toString()); + setSpotRotation(values.value(::settings::spotRotation, m_spotRotation).toDouble()); + setShowBorder(values.value(::settings::showBorder, m_showBorder).toBool()); + setBorderColor(values.value(::settings::borderColor, m_borderColor).value()); + setBorderSize(values.value(::settings::borderSize, m_borderSize).toInt()); + setBorderOpacity(values.value(::settings::borderOpacity, m_borderOpacity).toDouble()); + setZoomEnabled(values.value(::settings::zoomEnabled, m_zoomEnabled).toBool()); + setZoomFactor(values.value(::settings::zoomFactor, m_zoomFactor).toDouble()); + setZoomMode(values.value(::settings::zoomMode, m_zoomMode).toString()); + setMultiScreenOverlayEnabled( + values.value(::settings::multiScreenOverlay, m_multiScreenOverlayEnabled).toBool()); + + for (const auto& shape : spotShapes()) + { + auto* propertyMap = shapeSettings(shape.name()); + if (!propertyMap) { continue; } + + for (const auto& setting : shape.shapeSettings()) { + const auto key = QString("Shape.%1/%2").arg(shape.name(), setting.settingsKey()); + propertyMap->setProperty( + setting.settingsKey().toLocal8Bit(), + values.value(key, propertyMap->property(setting.settingsKey().toLocal8Bit()))); + } + } +} + +// ------------------------------------------------------------------------------------------------- +KCoreConfigSkeleton* Settings::configSkeleton() const +{ + return m_config.get(); +} + // ------------------------------------------------------------------------------------------------- void Settings::setDefaults() { @@ -306,6 +539,7 @@ void Settings::setDefaults() setBorderOpacity(settings::defaultValue::borderOpacity); setZoomEnabled(settings::defaultValue::zoomEnabled); setZoomFactor(settings::defaultValue::zoomFactor); + setZoomMode(settings::defaultValue::zoomMode); setMultiScreenOverlayEnabled(settings::defaultValue::multiScreenOverlay); shapeSettingsSetDefaults(); } @@ -344,19 +578,12 @@ void Settings::shapeSettingsLoad(const QString& preset) { const QString& key = settingDefinition.settingsKey(); const QString settingsKey = section + QString("Shape.%1/%2").arg(shape.name()).arg(key); - const QVariant loadedValue = m_settings->value(settingsKey, settingDefinition.defaultValue()); + const QVariant loadedValue = readValue(settingsKey, settingDefinition.defaultValue()); - #if (QT_VERSION < QT_VERSION_CHECK(6, 0, 0)) - if (settingDefinition.defaultValue().type() == QVariant::Int // Currently only int shape settings supported - && settingDefinition.defaultValue() != loadedValue) { - logDebug(lcSettings) << QString("spot.shape.%1.%2 = ").arg(shape.name().toLower(), key) << loadedValue.toInt(); - } - #else if (settingDefinition.defaultValue().metaType().id() == QMetaType::Int // Currently only int shape settings supported && settingDefinition.defaultValue() != loadedValue) { - logDebug(lcSettings) << QString("spot.shape.%1.%2 = ").arg(shape.name().toLower(), key) << loadedValue.toInt(); + qCDebug(PROJECTEUR_SETTINGS_LOG).noquote() << QString("spot.shape.%1.%2 = ").arg(shape.name().toLower(), key) << loadedValue.toInt(); } - #endif if (propertyMap->property(key.toLocal8Bit()).isValid()) { propertyMap->setProperty(key.toLocal8Bit(), loadedValue); @@ -382,7 +609,7 @@ void Settings::shapeSettingsSavePreset(const QString& preset) { const QString& key = settingDefinition.settingsKey(); const QString settingsKey = section + QString("Shape.%1/%2").arg(shape.name()).arg(key); - m_settings->setValue(settingsKey, propertyMap->property(key.toLocal8Bit())); + writeValue(settingsKey, propertyMap->property(key.toLocal8Bit())); } } } @@ -395,7 +622,7 @@ void Settings::shapeSettingsInitialize() { if (shape.shapeSettings().size() && m_shapeSettings.count(shape.name()) == 0) { - auto pm = new QQmlPropertyMap(this); + auto pm = createQmlPropertyMap(this); connect(pm, &QQmlPropertyMap::valueChanged, this, [this, shape, pm](const QString& key, const QVariant& value) { @@ -406,11 +633,7 @@ void Settings::shapeSettingsInitialize() if (it != s.cend()) { - #if (QT_VERSION < QT_VERSION_CHECK(6, 0, 0)) - if (it->defaultValue().type() == QVariant::Int) // Currently only int shape settings supported - #else if (it->defaultValue().metaType().id() == QMetaType::Int) - #endif { const auto setValue = value.toInt(); const auto min = it->minValue().toInt(); @@ -419,9 +642,9 @@ void Settings::shapeSettingsInitialize() if (newValue != setValue) { pm->setProperty(key.toLocal8Bit(), newValue); } - logDebug(lcSettings) << QString("spot.shape.%1.%2 = ").arg(shape.name().toLower(), it->settingsKey()) + qCDebug(PROJECTEUR_SETTINGS_LOG).noquote() << QString("spot.shape.%1.%2 = ").arg(shape.name().toLower(), it->settingsKey()) << setValue; - m_settings->setValue(QString("Shape.%1/%2").arg(shape.name()).arg(key), newValue); + writeValue(QString("Shape.%1/%2").arg(shape.name()).arg(key), newValue); } } }); @@ -445,7 +668,7 @@ void Settings::loadPreset(const QString& preset) void Settings::removePreset(const QString& preset) { m_presetModel->removePreset(preset); - m_settings->remove(presetSection(preset, false)); + remove(presetSection(preset, false)); } // ------------------------------------------------------------------------------------------------- @@ -463,28 +686,54 @@ PresetModel* Settings::presetModel() // ------------------------------------------------------------------------------------------------- void Settings::load(const QString& preset) { - logDebug(lcSettings) << tr("Loading values from config:") << m_settings->fileName() + qCDebug(PROJECTEUR_SETTINGS_LOG).noquote() << QStringLiteral("Loading values from config:") << configFileName() << (preset.size() ? QString("(%1)").arg(preset) : ""); + if (preset.isEmpty()) + { + setShowSpotShade(m_config->showSpotShade()); + setSpotSize(m_config->spotSize()); + setShowCenterDot(m_config->showCenterDot()); + setDotSize(m_config->dotSize()); + setDotColor(m_config->dotColor()); + setDotOpacity(m_config->dotOpacity()); + setShadeColor(m_config->shadeColor()); + setShadeOpacity(m_config->shadeOpacity()); + setCursor(static_cast(m_config->cursor())); + setSpotShape(m_config->spotShape()); + setSpotRotation(m_config->spotRotation()); + setShowBorder(m_config->showBorder()); + setBorderColor(m_config->borderColor()); + setBorderSize(m_config->borderSize()); + setBorderOpacity(m_config->borderOpacity()); + setZoomEnabled(m_config->zoomEnabled()); + setZoomFactor(m_config->zoomFactor()); + setZoomMode(m_config->zoomMode()); + setMultiScreenOverlayEnabled(m_config->multiScreenOverlay()); + shapeSettingsLoad(); + return; + } + const auto s = preset.size() ? presetSection(preset) : ""; - setShowSpotShade(m_settings->value(s+::settings::showSpotShade, settings::defaultValue::showSpotShade).toBool()); - setSpotSize(m_settings->value(s+::settings::spotSize, settings::defaultValue::spotSize).toInt()); - setShowCenterDot(m_settings->value(s+::settings::showCenterDot, settings::defaultValue::showCenterDot).toBool()); - setDotSize(m_settings->value(s+::settings::dotSize, settings::defaultValue::dotSize).toInt()); - setDotColor(m_settings->value(s+::settings::dotColor, QColor(settings::defaultValue::dotColor)).value()); - setDotOpacity(m_settings->value(s+::settings::dotOpacity, settings::defaultValue::dotOpacity).toDouble()); - setShadeColor(m_settings->value(s+::settings::shadeColor, QColor(settings::defaultValue::shadeColor)).value()); - setShadeOpacity(m_settings->value(s+::settings::shadeOpacity, settings::defaultValue::shadeOpacity).toDouble()); - setCursor(static_cast(m_settings->value(s+::settings::cursor, static_cast(settings::defaultValue::cursor)).toInt())); - setSpotShape(m_settings->value(s+::settings::spotShape, settings::defaultValue::spotShape).toString()); - setSpotRotation(m_settings->value(s+::settings::spotRotation, settings::defaultValue::spotRotation).toDouble()); - setShowBorder(m_settings->value(s+::settings::showBorder, settings::defaultValue::showBorder).toBool()); - setBorderColor(m_settings->value(s+::settings::borderColor, QColor(settings::defaultValue::borderColor)).value()); - setBorderSize(m_settings->value(s+::settings::borderSize, settings::defaultValue::borderSize).toInt()); - setBorderOpacity(m_settings->value(s+::settings::borderOpacity, settings::defaultValue::borderOpacity).toDouble()); - setZoomEnabled(m_settings->value(s+::settings::zoomEnabled, settings::defaultValue::zoomEnabled).toBool()); - setZoomFactor(m_settings->value(s+::settings::zoomFactor, settings::defaultValue::zoomFactor).toDouble()); - setMultiScreenOverlayEnabled(m_settings->value(s+::settings::multiScreenOverlay, settings::defaultValue::multiScreenOverlay).toBool()); + setShowSpotShade(readValue(s+::settings::showSpotShade, settings::defaultValue::showSpotShade).toBool()); + setSpotSize(readValue(s+::settings::spotSize, settings::defaultValue::spotSize).toInt()); + setShowCenterDot(readValue(s+::settings::showCenterDot, settings::defaultValue::showCenterDot).toBool()); + setDotSize(readValue(s+::settings::dotSize, settings::defaultValue::dotSize).toInt()); + setDotColor(readValue(s+::settings::dotColor, QColor(settings::defaultValue::dotColor)).value()); + setDotOpacity(readValue(s+::settings::dotOpacity, settings::defaultValue::dotOpacity).toDouble()); + setShadeColor(readValue(s+::settings::shadeColor, QColor(settings::defaultValue::shadeColor)).value()); + setShadeOpacity(readValue(s+::settings::shadeOpacity, settings::defaultValue::shadeOpacity).toDouble()); + setCursor(static_cast(readValue(s+::settings::cursor, static_cast(settings::defaultValue::cursor)).toInt())); + setSpotShape(readValue(s+::settings::spotShape, settings::defaultValue::spotShape).toString()); + setSpotRotation(readValue(s+::settings::spotRotation, settings::defaultValue::spotRotation).toDouble()); + setShowBorder(readValue(s+::settings::showBorder, settings::defaultValue::showBorder).toBool()); + setBorderColor(readValue(s+::settings::borderColor, QColor(settings::defaultValue::borderColor)).value()); + setBorderSize(readValue(s+::settings::borderSize, settings::defaultValue::borderSize).toInt()); + setBorderOpacity(readValue(s+::settings::borderOpacity, settings::defaultValue::borderOpacity).toDouble()); + setZoomEnabled(readValue(s+::settings::zoomEnabled, settings::defaultValue::zoomEnabled).toBool()); + setZoomFactor(readValue(s+::settings::zoomFactor, settings::defaultValue::zoomFactor).toDouble()); + setZoomMode(readValue(s+::settings::zoomMode, settings::defaultValue::zoomMode).toString()); + setMultiScreenOverlayEnabled(readValue(s+::settings::multiScreenOverlay, settings::defaultValue::multiScreenOverlay).toBool()); shapeSettingsLoad(preset); } @@ -493,24 +742,25 @@ void Settings::savePreset(const QString& preset) { const auto section = presetSection(preset); - m_settings->setValue(section+::settings::showSpotShade, m_showSpotShade); - m_settings->setValue(section+::settings::spotSize, m_spotSize); - m_settings->setValue(section+::settings::showCenterDot, m_showCenterDot); - m_settings->setValue(section+::settings::dotSize, m_dotSize); - m_settings->setValue(section+::settings::dotColor, m_dotColor); - m_settings->setValue(section+::settings::dotOpacity, m_dotOpacity); - m_settings->setValue(section+::settings::shadeColor, m_shadeColor); - m_settings->setValue(section+::settings::shadeOpacity, m_shadeOpacity); - m_settings->setValue(section+::settings::cursor, static_cast(m_cursor)); - m_settings->setValue(section+::settings::spotShape, m_spotShape); - m_settings->setValue(section+::settings::spotRotation, m_spotRotation); - m_settings->setValue(section+::settings::showBorder, m_showBorder); - m_settings->setValue(section+::settings::borderColor, m_borderColor); - m_settings->setValue(section+::settings::borderSize, m_borderSize); - m_settings->setValue(section+::settings::borderOpacity, m_borderOpacity); - m_settings->setValue(section+::settings::zoomEnabled, m_zoomEnabled); - m_settings->setValue(section+::settings::zoomFactor, m_zoomFactor); - m_settings->setValue(section+::settings::multiScreenOverlay, m_multiScreenOverlayEnabled); + writeValue(section+::settings::showSpotShade, m_showSpotShade); + writeValue(section+::settings::spotSize, m_spotSize); + writeValue(section+::settings::showCenterDot, m_showCenterDot); + writeValue(section+::settings::dotSize, m_dotSize); + writeValue(section+::settings::dotColor, m_dotColor); + writeValue(section+::settings::dotOpacity, m_dotOpacity); + writeValue(section+::settings::shadeColor, m_shadeColor); + writeValue(section+::settings::shadeOpacity, m_shadeOpacity); + writeValue(section+::settings::cursor, static_cast(m_cursor)); + writeValue(section+::settings::spotShape, m_spotShape); + writeValue(section+::settings::spotRotation, m_spotRotation); + writeValue(section+::settings::showBorder, m_showBorder); + writeValue(section+::settings::borderColor, m_borderColor); + writeValue(section+::settings::borderSize, m_borderSize); + writeValue(section+::settings::borderOpacity, m_borderOpacity); + writeValue(section+::settings::zoomEnabled, m_zoomEnabled); + writeValue(section+::settings::zoomFactor, m_zoomFactor); + writeValue(section+::settings::zoomMode, m_zoomMode); + writeValue(section+::settings::multiScreenOverlay, m_multiScreenOverlayEnabled); shapeSettingsSavePreset(preset); m_presetModel->addPreset(preset); @@ -523,8 +773,9 @@ void Settings::setShowSpotShade(bool show) if (show == m_showSpotShade) { return; } m_showSpotShade = show; - m_settings->setValue(::settings::showSpotShade, m_showSpotShade); - logDebug(lcSettings) << "shade =" << m_showSpotShade; + m_config->setShowSpotShade(m_showSpotShade); + save(); + qCDebug(PROJECTEUR_SETTINGS_LOG).noquote() << "shade =" << m_showSpotShade; emit showSpotShadeChanged(m_showSpotShade); } @@ -534,8 +785,9 @@ void Settings::setSpotSize(int size) if (size == m_spotSize) { return; } m_spotSize = qMin(qMax(::settings::ranges::spotSize.min, size), ::settings::ranges::spotSize.max); - m_settings->setValue(::settings::spotSize, m_spotSize); - logDebug(lcSettings) << "spot.size =" << m_spotSize; + m_config->setSpotSize(m_spotSize); + save(); + qCDebug(PROJECTEUR_SETTINGS_LOG).noquote() << "spot.size =" << m_spotSize; emit spotSizeChanged(m_spotSize); } @@ -545,8 +797,9 @@ void Settings::setShowCenterDot(bool show) if (show == m_showCenterDot) { return; } m_showCenterDot = show; - m_settings->setValue(::settings::showCenterDot, m_showCenterDot); - logDebug(lcSettings) << "dot =" << m_showCenterDot; + m_config->setShowCenterDot(m_showCenterDot); + save(); + qCDebug(PROJECTEUR_SETTINGS_LOG).noquote() << "dot =" << m_showCenterDot; emit showCenterDotChanged(m_showCenterDot); } @@ -556,8 +809,9 @@ void Settings::setDotSize(int size) if (size == m_dotSize) { return; } m_dotSize = qMin(qMax(::settings::ranges::dotSize.min, size), ::settings::ranges::dotSize.max); - m_settings->setValue(::settings::dotSize, m_dotSize); - logDebug(lcSettings) << "dot.size =" << m_dotSize; + m_config->setDotSize(m_dotSize); + save(); + qCDebug(PROJECTEUR_SETTINGS_LOG).noquote() << "dot.size =" << m_dotSize; emit dotSizeChanged(m_dotSize); } @@ -567,8 +821,9 @@ void Settings::setDotColor(const QColor& color) if (color == m_dotColor) { return; } m_dotColor = color; - m_settings->setValue(::settings::dotColor, m_dotColor); - logDebug(lcSettings) << "dot.color =" << m_dotColor.name(); + m_config->setDotColor(m_dotColor); + save(); + qCDebug(PROJECTEUR_SETTINGS_LOG).noquote() << "dot.color =" << m_dotColor.name(); emit dotColorChanged(m_dotColor); } @@ -578,8 +833,9 @@ void Settings::setDotOpacity(double opacity) if (opacity > m_dotOpacity || opacity < m_dotOpacity) { m_dotOpacity = qMin(qMax(::settings::ranges::dotOpacity.min, opacity), ::settings::ranges::dotOpacity.max); - m_settings->setValue(::settings::dotOpacity, m_dotOpacity); - logDebug(lcSettings) << "dot.opacity = " << m_dotOpacity; + m_config->setDotOpacity(m_dotOpacity); + save(); + qCDebug(PROJECTEUR_SETTINGS_LOG).noquote() << "dot.opacity = " << m_dotOpacity; emit dotOpacityChanged(m_dotOpacity); } } @@ -590,8 +846,9 @@ void Settings::setShadeColor(const QColor& color) if (color == m_shadeColor) { return; } m_shadeColor = color; - m_settings->setValue(::settings::shadeColor, m_shadeColor); - logDebug(lcSettings) << "shade.color =" << m_shadeColor.name(); + m_config->setShadeColor(m_shadeColor); + save(); + qCDebug(PROJECTEUR_SETTINGS_LOG).noquote() << "shade.color =" << m_shadeColor.name(); emit shadeColorChanged(m_shadeColor); } @@ -601,8 +858,9 @@ void Settings::setShadeOpacity(double opacity) if (opacity > m_shadeOpacity || opacity < m_shadeOpacity) { m_shadeOpacity = qMin(qMax(::settings::ranges::shadeOpacity.min, opacity), ::settings::ranges::shadeOpacity.max); - m_settings->setValue(::settings::shadeOpacity, m_shadeOpacity); - logDebug(lcSettings) << "shade.opacity = " << m_shadeOpacity; + m_config->setShadeOpacity(m_shadeOpacity); + save(); + qCDebug(PROJECTEUR_SETTINGS_LOG).noquote() << "shade.opacity = " << m_shadeOpacity; emit shadeOpacityChanged(m_shadeOpacity); } } @@ -613,8 +871,9 @@ void Settings::setCursor(Qt::CursorShape cursor) if (cursor == m_cursor) { return; } m_cursor = qMin(qMax(static_cast(0), cursor), Qt::LastCursor); - m_settings->setValue(::settings::cursor, static_cast(m_cursor)); - logDebug(lcSettings) << "cursor = " << m_cursor; + m_config->setCursor(static_cast(m_cursor)); + save(); + qCDebug(PROJECTEUR_SETTINGS_LOG).noquote() << "cursor = " << m_cursor; emit cursorChanged(m_cursor); } @@ -630,8 +889,9 @@ void Settings::setSpotShape(const QString& spotShapeQmlComponent) if (it != spotShapes().cend()) { m_spotShape = it->qmlComponent(); - m_settings->setValue(::settings::spotShape, m_spotShape); - logDebug(lcSettings) << "spot.shape = " << m_spotShape; + m_config->setSpotShape(m_spotShape); + save(); + qCDebug(PROJECTEUR_SETTINGS_LOG).noquote() << "spot.shape = " << m_spotShape; emit spotShapeChanged(m_spotShape); setSpotRotationAllowed(it->allowRotation()); } @@ -643,8 +903,9 @@ void Settings::setSpotRotation(double rotation) if (rotation > m_spotRotation || rotation < m_spotRotation) { m_spotRotation = qMin(qMax(::settings::ranges::spotRotation.min, rotation), ::settings::ranges::spotRotation.max); - m_settings->setValue(::settings::spotRotation, m_spotRotation); - logDebug(lcSettings) << "spot.rotation = " << m_spotRotation; + m_config->setSpotRotation(m_spotRotation); + save(); + qCDebug(PROJECTEUR_SETTINGS_LOG).noquote() << "spot.rotation = " << m_spotRotation; emit spotRotationChanged(m_spotRotation); } } @@ -699,8 +960,9 @@ void Settings::setShowBorder(bool show) if (show == m_showBorder) { return; } m_showBorder = show; - m_settings->setValue(::settings::showBorder, m_showBorder); - logDebug(lcSettings) << "border = " << m_showBorder; + m_config->setShowBorder(m_showBorder); + save(); + qCDebug(PROJECTEUR_SETTINGS_LOG).noquote() << "border = " << m_showBorder; emit showBorderChanged(m_showBorder); } @@ -710,8 +972,9 @@ void Settings::setBorderColor(const QColor& color) if (color == m_borderColor) { return; } m_borderColor = color; - m_settings->setValue(::settings::borderColor, m_borderColor); - logDebug(lcSettings) << "border.color = " << m_borderColor.name(); + m_config->setBorderColor(m_borderColor); + save(); + qCDebug(PROJECTEUR_SETTINGS_LOG).noquote() << "border.color = " << m_borderColor.name(); emit borderColorChanged(m_borderColor); } @@ -721,8 +984,9 @@ void Settings::setBorderSize(int size) if (size == m_borderSize) { return; } m_borderSize = qMin(qMax(::settings::ranges::borderSize.min, size), ::settings::ranges::borderSize.max); - m_settings->setValue(::settings::borderSize, m_borderSize); - logDebug(lcSettings) << "border.size = " << m_borderSize; + m_config->setBorderSize(m_borderSize); + save(); + qCDebug(PROJECTEUR_SETTINGS_LOG).noquote() << "border.size = " << m_borderSize; emit borderSizeChanged(m_borderSize); } @@ -732,8 +996,9 @@ void Settings::setBorderOpacity(double opacity) if (opacity > m_borderOpacity || opacity < m_borderOpacity) { m_borderOpacity = qMin(qMax(::settings::ranges::borderOpacity.min, opacity), ::settings::ranges::borderOpacity.max); - m_settings->setValue(::settings::borderOpacity, m_borderOpacity); - logDebug(lcSettings) << "border.opacity = " << m_borderOpacity; + m_config->setBorderOpacity(m_borderOpacity); + save(); + qCDebug(PROJECTEUR_SETTINGS_LOG).noquote() << "border.opacity = " << m_borderOpacity; emit borderOpacityChanged(m_borderOpacity); } } @@ -744,8 +1009,9 @@ void Settings::setZoomEnabled(bool enabled) if (enabled == m_zoomEnabled) { return; } m_zoomEnabled = enabled; - m_settings->setValue(::settings::zoomEnabled, m_zoomEnabled); - logDebug(lcSettings) << "zoom = " << m_zoomEnabled; + m_config->setZoomEnabled(m_zoomEnabled); + save(); + qCDebug(PROJECTEUR_SETTINGS_LOG).noquote() << "zoom = " << m_zoomEnabled; emit zoomEnabledChanged(m_zoomEnabled); } @@ -755,19 +1021,34 @@ void Settings::setZoomFactor(double factor) if (factor > m_zoomFactor || factor < m_zoomFactor) { m_zoomFactor = qMin(qMax(::settings::ranges::zoomFactor.min, factor), ::settings::ranges::zoomFactor.max); - m_settings->setValue(::settings::zoomFactor, m_zoomFactor); - logDebug(lcSettings) << "zoom.factor = " << m_zoomFactor; + m_config->setZoomFactor(m_zoomFactor); + save(); + qCDebug(PROJECTEUR_SETTINGS_LOG).noquote() << "zoom.factor = " << m_zoomFactor; emit zoomFactorChanged(m_zoomFactor); } } +// ------------------------------------------------------------------------------------------------- +void Settings::setZoomMode(const QString& mode) +{ + const auto normalizedMode = mode.trimmed().toLower(); + if (!isZoomMode(normalizedMode) || normalizedMode == m_zoomMode) { return; } + + m_zoomMode = normalizedMode; + m_config->setZoomMode(m_zoomMode); + save(); + qCDebug(PROJECTEUR_SETTINGS_LOG).noquote() << "zoom.mode = " << m_zoomMode; + emit zoomModeChanged(m_zoomMode); +} + // ------------------------------------------------------------------------------------------------- void Settings::setMultiScreenOverlayEnabled(bool enabled) { if (m_multiScreenOverlayEnabled == enabled) { return; } m_multiScreenOverlayEnabled = enabled; - m_settings->setValue(::settings::multiScreenOverlay, m_multiScreenOverlayEnabled); - logDebug(lcSettings) << "multi-screen-overlay = " << m_multiScreenOverlayEnabled; + m_config->setMultiScreenOverlay(m_multiScreenOverlayEnabled); + save(); + qCDebug(PROJECTEUR_SETTINGS_LOG).noquote() << "multi-screen-overlay = " << m_multiScreenOverlayEnabled; emit multiScreenOverlayEnabledChanged(m_multiScreenOverlayEnabled); } @@ -797,13 +1078,13 @@ void Settings::setDeviceInputSeqInterval(const DeviceId& dId, int intervalMs) { const auto v = qMin(qMax(::settings::ranges::inputSequenceInterval.min, intervalMs), ::settings::ranges::inputSequenceInterval.max); - m_settings->setValue(settingsKey(dId, ::settings::inputSequenceInterval), v); + writeValue(settingsKey(dId, ::settings::inputSequenceInterval), v); } // ------------------------------------------------------------------------------------------------- int Settings::deviceInputSeqInterval(const DeviceId& dId) const { - const auto value = m_settings->value(settingsKey(dId, ::settings::inputSequenceInterval), + const auto value = readValue(settingsKey(dId, ::settings::inputSequenceInterval), ::settings::defaultValue::inputSequenceInterval).toInt(); return qMin(qMax(::settings::ranges::inputSequenceInterval.min, value), ::settings::ranges::inputSequenceInterval.max); @@ -812,24 +1093,15 @@ int Settings::deviceInputSeqInterval(const DeviceId& dId) const // ------------------------------------------------------------------------------------------------- void Settings::setDeviceInputMapConfig(const DeviceId& dId, const InputMapConfig& imc) { - const int sizeBefore = m_settings->value(settingsKey(dId, ::settings::inputMapConfig) - + "/size", 0).toInt(); - m_settings->beginWriteArray(settingsKey(dId, ::settings::inputMapConfig), imc.size()); - int index = 0; + QByteArray serialized; + QDataStream stream(&serialized, QIODevice::WriteOnly); + stream.setVersion(QDataStream::Qt_6_0); + stream << inputMapFormatVersion << quint32(imc.size()); for (const auto& item : imc) { - m_settings->setArrayIndex(index++); - m_settings->setValue("deviceSequence", QVariant::fromValue(item.first)); - m_settings->setValue("mappedAction", QVariant::fromValue(item.second)); + stream << item.first << item.second; } - m_settings->endArray(); - - // Remove old entries... - m_settings->beginGroup(settingsKey(dId, ::settings::inputMapConfig)); - for (; index < sizeBefore; ++index) { - m_settings->remove(QString::number(index+1)); - } - m_settings->endGroup(); + writeValue(settingsKey(dId, inputMapConfigDataKey), serialized); } // ------------------------------------------------------------------------------------------------- @@ -837,15 +1109,31 @@ InputMapConfig Settings::getDeviceInputMapConfig(const DeviceId& dId) { InputMapConfig cfg; - const int size = m_settings->beginReadArray(settingsKey(dId, ::settings::inputMapConfig)); - for (int i = 0; i < size; ++i) + const auto serialized = + readValue(settingsKey(dId, inputMapConfigDataKey), QByteArray()).toByteArray(); + if (serialized.isEmpty()) { + return cfg; + } + + QDataStream stream(serialized); + stream.setVersion(QDataStream::Qt_6_0); + quint32 version = 0; + quint32 size = 0; + stream >> version >> size; + if (version != inputMapFormatVersion || size > 1024) { + qCWarning(PROJECTEUR_SETTINGS_LOG).noquote() << QStringLiteral("Ignoring unsupported device input mapping data."); + return cfg; + } + + for (quint32 i = 0; i < size; ++i) { - m_settings->setArrayIndex(i); - const auto seq = m_settings->value("deviceSequence"); - if (!seq.canConvert()) { continue; } - const auto conf = m_settings->value("mappedAction"); - if (!conf.canConvert()) { continue; } - auto mappedAction = qvariant_cast(conf); + KeyEventSequence sequence; + MappedAction mappedAction; + stream >> sequence >> mappedAction; + if (stream.status() != QDataStream::Ok || !mappedAction.action) { + qCWarning(PROJECTEUR_SETTINGS_LOG).noquote() << QStringLiteral("Ignoring invalid device input mapping data."); + return {}; + } if (mappedAction.action->type() == Action::Type::ScrollHorizontal) { mappedAction.action = GlobalActions::scrollHorizontal(); } else if (mappedAction.action->type() == Action::Type::ScrollVertical) { @@ -853,50 +1141,57 @@ InputMapConfig Settings::getDeviceInputMapConfig(const DeviceId& dId) } else if (mappedAction.action->type() == Action::Type::VolumeControl) { mappedAction.action = GlobalActions::volumeControl(); } - cfg.emplace(qvariant_cast(seq), std::move(mappedAction)); + cfg.emplace(std::move(sequence), std::move(mappedAction)); } - m_settings->endArray(); return cfg; } // ------------------------------------------------------------------------------------------------- -void Settings::setTimerSettings(const DeviceId& dId, int timerId, bool enabled, int seconds) +void Settings::setDevicePresentationTimerHapticStrength(const DeviceId& dId, int strength) +{ + writeValue( + settingsKey(dId, ::settings::presentationTimerHapticStrength), + std::clamp(strength, 0, 100)); +} + +// ------------------------------------------------------------------------------------------------- +int Settings::devicePresentationTimerHapticStrength(const DeviceId& dId) const { - m_settings->setValue(settingsKey(dId, QString(::settings::timerEnabled).arg(timerId)), enabled); - m_settings->setValue(settingsKey(dId, QString(::settings::timerSeconds).arg(timerId)), seconds); + const auto deviceKey = settingsKey(dId, ::settings::presentationTimerHapticStrength); + if (contains(deviceKey)) { + return std::clamp(readValue(deviceKey).toInt(), 0, 100); + } + + return ::settings::defaultValue::presentationTimerHapticStrength; } // ------------------------------------------------------------------------------------------------- -std::pair Settings::timerSettings(const DeviceId& dId, int timerId) const +void Settings::setPresentationTimerEnabled(bool enabled) { - const auto enabled = m_settings->value( - settingsKey(dId, QString(::settings::timerEnabled).arg(timerId)), false).toBool(); - const auto seconds = m_settings->value( - settingsKey(dId, QString(::settings::timerSeconds).arg(timerId)), 900 + 900 * timerId).toInt(); - return std::make_pair(enabled, seconds); + m_config->setPresentationTimerEnabled(enabled); + save(); } // ------------------------------------------------------------------------------------------------- -void Settings::setVibrationSettings(const DeviceId& dId, uint8_t len, uint8_t intensity) +bool Settings::presentationTimerEnabled() const { - m_settings->setValue(settingsKey(dId, ::settings::vibrationLength), len); - m_settings->setValue(settingsKey(dId, ::settings::vibrationIntensity), intensity); + return m_config->presentationTimerEnabled(); } // ------------------------------------------------------------------------------------------------- -std::pair Settings::vibrationSettings(const DeviceId& dId) const +void Settings::setPresentationTimerDurationSeconds(int seconds) { - const auto len = m_settings->value( - settingsKey(dId, ::settings::vibrationLength), - ::settings::defaultValue::vibrationLength).toUInt(); - const auto intensity = m_settings->value( - settingsKey(dId, ::settings::vibrationIntensity), - ::settings::defaultValue::vibrationIntensity).toUInt(); - return std::make_pair(len, intensity); + m_config->setPresentationTimerDurationSeconds(seconds); + save(); } // ------------------------------------------------------------------------------------------------- +int Settings::presentationTimerDurationSeconds() const +{ + return m_config->presentationTimerDurationSeconds(); +} + // ------------------------------------------------------------------------------------------------- PresetModel::PresetModel(QObject* parent) : PresetModel({}, parent) @@ -926,7 +1221,7 @@ QVariant PresetModel::data(const QModelIndex& index, int role) const if (role == Qt::DisplayRole) { if (index.row() == 0) { - return tr("Current Settings"); + return i18n("Current Settings"); } return m_presets[index.row()-1]; @@ -977,5 +1272,3 @@ void PresetModel::removePreset(const QString& preset) m_presets.erase(r.first, r.second); endRemoveRows(); } - - diff --git a/src/settings.h b/src/settings.h index f5cc11e4..8f41796c 100644 --- a/src/settings.h +++ b/src/settings.h @@ -4,6 +4,7 @@ #include #include +#include #include #include @@ -12,8 +13,9 @@ struct DeviceId; class InputMapConfig; +class KCoreConfigSkeleton; class PresetModel; -class QSettings; +class ProjecteurConfig; class QQmlPropertyMap; // ------------------------------------------------------------------------------------------------- @@ -39,9 +41,9 @@ class Settings : public QObject Q_PROPERTY(double borderOpacity READ borderOpacity WRITE setBorderOpacity NOTIFY borderOpacityChanged) Q_PROPERTY(bool zoomEnabled READ zoomEnabled WRITE setZoomEnabled NOTIFY zoomEnabledChanged) Q_PROPERTY(double zoomFactor READ zoomFactor WRITE setZoomFactor NOTIFY zoomFactorChanged) + Q_PROPERTY(QString zoomMode READ zoomMode WRITE setZoomMode NOTIFY zoomModeChanged) Q_PROPERTY(bool multiScreenOverlayEnabled READ multiScreenOverlayEnabled WRITE setMultiScreenOverlayEnabled NOTIFY multiScreenOverlayEnabledChanged) - public: explicit Settings(QObject* parent = nullptr); explicit Settings(const QString& configFile, QObject* parent = nullptr); @@ -84,6 +86,8 @@ class Settings : public QObject void setZoomEnabled(bool enabled); double zoomFactor() const { return m_zoomFactor; } void setZoomFactor(double factor); + QString zoomMode() const { return m_zoomMode; } + void setZoomMode(const QString& mode); bool multiScreenOverlayEnabled() const { return m_multiScreenOverlayEnabled; } void setMultiScreenOverlayEnabled(bool enabled); bool overlayDisabled() const { return m_overlayDisabled; } @@ -149,6 +153,12 @@ class Settings : public QObject static const QList& spotShapes(); QQmlPropertyMap* shapeSettings(const QString& shapeName); + using SpotlightSettings = QVariantMap; + SpotlightSettings spotlightSettings() const; + static SpotlightSettings defaultSpotlightSettings(); + void setSpotlightSettings(const SpotlightSettings& values); + KCoreConfigSkeleton* configSkeleton() const; + struct StringProperty { enum Type { Integer, Double, Bool, StringEnum, Color }; @@ -171,12 +181,13 @@ class Settings : public QObject int deviceInputSeqInterval(const DeviceId& dId) const; void setDeviceInputMapConfig(const DeviceId& dId, const InputMapConfig& imc); InputMapConfig getDeviceInputMapConfig(const DeviceId& dId); + void setDevicePresentationTimerHapticStrength(const DeviceId& dId, int strength); + int devicePresentationTimerHapticStrength(const DeviceId& dId) const; - void setTimerSettings(const DeviceId& dId, int timerId, bool enabled, int seconds); - std::pair timerSettings(const DeviceId& dId, int timerId) const; - - void setVibrationSettings(const DeviceId& dId, uint8_t len, uint8_t intensity); - std::pair vibrationSettings(const DeviceId& dId) const; + void setPresentationTimerEnabled(bool enabled); + bool presentationTimerEnabled() const; + void setPresentationTimerDurationSeconds(int seconds); + int presentationTimerDurationSeconds() const; signals: void showSpotShadeChanged(bool show); @@ -197,15 +208,16 @@ class Settings : public QObject void borderOpacityChanged(double opacity); void zoomEnabledChanged(bool enabled); void zoomFactorChanged(double zoomFactor); + void zoomModeChanged(const QString& mode); void multiScreenOverlayEnabledChanged(bool enabled); void overlayDisabledChanged(bool disabled); void presetLoaded(const QString& preset); private: - QSettings* m_settings = nullptr; + std::unique_ptr m_config; - PresetModel* m_presetModel; + PresetModel* m_presetModel = nullptr; std::map m_shapeSettings; QQmlPropertyMap* m_shapeSettingsRoot = nullptr; @@ -223,6 +235,7 @@ class Settings : public QObject double m_borderOpacity = 0.8; bool m_zoomEnabled = false; double m_zoomFactor = 2.0; + QString m_zoomMode = QStringLiteral("smooth"); bool m_showSpotShade = true; bool m_showCenterDot = false; bool m_spotRotationAllowed = false; @@ -234,6 +247,13 @@ class Settings : public QObject private: void init(); + QVariant readValue(const QString& path, const QVariant& defaultValue = {}) const; + void writeValue(const QString& path, const QVariant& value); + bool contains(const QString& path) const; + void remove(const QString& path); + QString configFileName() const; + void save(); + void sync(); void load(const QString& preset = QString()); QObject* shapeSettingsRootObject(); void shapeSettingsPopulateRoot(); diff --git a/src/spotlight.cc b/src/spotlight.cc index 20bd96fc..7172903b 100644 --- a/src/spotlight.cc +++ b/src/spotlight.cc @@ -5,10 +5,13 @@ #include "device-hidpp.h" #include "deviceinput.h" -#include "logging.h" +#include "projecteur_device_debug.h" +#include "projecteur_hid_debug.h" +#include "projecteur_input_debug.h" #include "settings.h" #include "virtualdevice.h" +#include #include #include #include @@ -19,15 +22,19 @@ #include #include -DECLARE_LOGGING_CATEGORY(device) -DECLARE_LOGGING_CATEGORY(hid) -DECLARE_LOGGING_CATEGORY(input) - namespace { - const auto hexId = logging::hexId; + const auto hexId = formatHexId; + + QElapsedTimer lastLogitechSlideNavigation; - // See details on workaround in onEventDataAvailable - bool workaroundLogitechFirstMoveEvent = true; + bool isLogitechSpotlight(const DeviceId& id) + { + return id.vendorId == 0x46d + && (id.productId == 0xc53e + || id.productId == 0xb503 + || id.productId == 0xc548 + || id.productId == 0xb506); + } } // end anonymous namespace @@ -86,7 +93,6 @@ Spotlight::Spotlight(QObject* parent, Options options, Settings* settings) connect(m_activeTimer, &QTimer::timeout, this, [this](){ setSpotActive(false); - workaroundLogitechFirstMoveEvent = true; }); if (m_options.enableUInput) { @@ -96,7 +102,7 @@ Spotlight::Spotlight(QObject* parent, Options options, Settings* settings) VirtualDevice::Type::Keyboard, "Projecteur_virtual_keyboard"); } else { - logInfo(device) << tr("Virtual device initialization was skipped."); + qCInfo(PROJECTEUR_DEVICE_LOG).noquote() << QStringLiteral("Virtual device initialization was skipped."); } m_connectionTimer->setSingleShot(true); @@ -107,7 +113,7 @@ Spotlight::Spotlight(QObject* parent, Options options, Settings* settings) m_connectionTimer->setInterval(delayedConnectionTimerIntervalMs); connect(m_connectionTimer, &QTimer::timeout, this, [this]() { - logDebug(device) << tr("New connection check triggered"); + qCDebug(PROJECTEUR_DEVICE_LOG).noquote() << QStringLiteral("New connection check triggered"); connectDevices(); }); @@ -184,10 +190,17 @@ int Spotlight::connectDevices() const bool anyConnectedBefore = anySpotlightDeviceConnected(); for (const auto& scanSubDevice : dev.subDevices) { - if (!scanSubDevice.deviceReadable) + const bool requiresWriteAccess = + scanSubDevice.type == DeviceScan::SubDevice::Type::Hidraw; + if (!scanSubDevice.deviceReadable + || (requiresWriteAccess && !scanSubDevice.deviceWritable)) { - logWarn(device) << tr("Sub-device not readable: %1 (%2:%3) %4") - .arg(dc->deviceName(), hexId(dev.id.vendorId), hexId(dev.id.productId), scanSubDevice.deviceFile); + qCWarning(PROJECTEUR_DEVICE_LOG).noquote() << QStringLiteral("Sub-device not accessible: %1 (%2:%3) %4").arg(dc->deviceName()).arg(hexId(dev.id.vendorId)).arg(hexId(dev.id.productId)).arg(scanSubDevice.deviceFile); + QTimer::singleShot( + 0, this, + [this, name = dc->deviceName(), path = scanSubDevice.deviceFile]() { + emit deviceAccessError(name, path); + }); continue; } if (dc->hasSubDevice(scanSubDevice.deviceFile)) { continue; } @@ -322,16 +335,13 @@ int Spotlight::connectDevices() { QTimer::singleShot(0, this, [this, id = dev.id, devName = dc->deviceName(), anyConnectedBefore](){ - logInfo(device) << tr("Connected device: %1 (%2:%3)") - .arg(devName, hexId(id.vendorId), hexId(id.productId)); + qCInfo(PROJECTEUR_DEVICE_LOG).noquote() << QStringLiteral("Connected device: %1 (%2:%3)").arg(devName).arg(hexId(id.vendorId)).arg(hexId(id.productId)); emit deviceConnected(id, devName); if (!anyConnectedBefore) { emit anySpotlightDeviceConnectedChanged(true); } }); } - logDebug(device) << tr("Connected sub-device: %1 (%2:%3) %4") - .arg(dc->deviceName(), hexId(dev.id.vendorId), - hexId(dev.id.productId), scanSubDevice.deviceFile); + qCDebug(PROJECTEUR_DEVICE_LOG).noquote() << QStringLiteral("Connected sub-device: %1 (%2:%3) %4").arg(dc->deviceName()).arg(hexId(dev.id.vendorId)).arg(hexId(dev.id.productId)).arg(scanSubDevice.deviceFile); emit subDeviceConnected(dev.id, dc->deviceName(), scanSubDevice.deviceFile); } @@ -359,9 +369,7 @@ void Spotlight::removeDeviceConnection(const QString &devicePath) if (dc->subDeviceCount() == 0) { - logInfo(device) << tr("Disconnected device: %1 (%2:%3)") - .arg(dc->deviceName(), hexId(dc_it->first.vendorId), - hexId(dc_it->first.productId)); + qCInfo(PROJECTEUR_DEVICE_LOG).noquote() << QStringLiteral("Disconnected device: %1 (%2:%3)").arg(dc->deviceName()).arg(hexId(dc_it->first.vendorId)).arg(hexId(dc_it->first.productId)); emit deviceDisconnected(dc_it->first, dc->deviceName()); dc_it = m_deviceConnections.erase(dc_it); } @@ -396,6 +404,19 @@ void Spotlight::onEventDataAvailable(int fd, SubEventConnection& connection) } ++buf; + const bool isSlideNavigationKey = + ev.type == EV_KEY + && (ev.code == KEY_RIGHT || ev.code == KEY_LEFT + || ev.code == KEY_PAGEDOWN || ev.code == KEY_PAGEUP); + if (isSlideNavigationKey) { + if (isLogitechSpotlight(connection.deviceId())) { + lastLogitechSlideNavigation.restart(); + } + if (ev.value == 1) { + emit slideNavigationPressed(); + } + } + if (ev.type == EV_SYN) { // Check for relative events -> set Spotlight active @@ -405,24 +426,19 @@ void Spotlight::onEventDataAvailable(int fd, SubEventConnection& connection) if (isMouseMoveEvent) { // Skip input mapping for mouse move events completely - // Note: During a Next or Back button press the Logitech Spotlight device can send // move events via hid++ notifications. It seems that just when releasing the // next or back button sometimes a mouse move event 'leaks' through here as // relative input event causing the spotlight to be activated. - // The workaround skips a first input move event from the logitech spotlight device. - const bool isLogitechSpotlight = connection.deviceId().vendorId == 0x46d - && (connection.deviceId().productId == 0xc53e || connection.deviceId().productId == 0xb503); - const bool logitechIsFirst = isLogitechSpotlight && workaroundLogitechFirstMoveEvent; - - if (isLogitechSpotlight) - { - workaroundLogitechFirstMoveEvent = false; - if(!logitechIsFirst) { - if (!spotActive()) { setSpotActive(true); } - } - } - else if (!m_activeTimer->isActive()) { + // Suppress only moves immediately adjacent to slide navigation; skipping the + // first move after every idle period makes genuine activation feel delayed. + constexpr qint64 leakedMoveSuppressionMs = 250; + const bool suppressLeakedMove = + isLogitechSpotlight(connection.deviceId()) + && lastLogitechSlideNavigation.isValid() + && lastLogitechSlideNavigation.elapsed() < leakedMoveSuppressionMs; + + if (!suppressLeakedMove && !spotActive()) { setSpotActive(true); } @@ -440,7 +456,8 @@ void Spotlight::onEventDataAvailable(int fd, SubEventConnection& connection) } else if (buf.pos() >= buf.size()) { // No idea if this will ever happen, but log it to make sure we get notified. - logWarning(device) << tr("Discarded %1 input events without EV_SYN.").arg(buf.size()); + qCWarning(PROJECTEUR_DEVICE_LOG).noquote() << "Discarded" << buf.size() + << "input events without EV_SYN."; connection.inputMapper()->resetState(); buf.reset(); } @@ -523,7 +540,6 @@ void Spotlight::registerForNotifications(SubHidppConnection* connection) const int adjustedY = getReducedParam(y); if (adjustedX == 0 && adjustedY == 0) { return; } - static const auto scrollHAction = GlobalActions::scrollHorizontal(); scrollHAction->param = -adjustedX; @@ -570,7 +586,7 @@ bool Spotlight::setupDevEventInotify() { fd = inotify_init(); if (fd == -1) { - logError(device) << tr("inotify_init() failed. Detection of new attached devices will not work."); + qCCritical(PROJECTEUR_DEVICE_LOG).noquote() << QStringLiteral("inotify_init() failed. Detection of new attached devices will not work."); return false; } } @@ -578,7 +594,7 @@ bool Spotlight::setupDevEventInotify() const int wd = inotify_add_watch(fd, "/dev/input", IN_CREATE | IN_DELETE); if (wd < 0) { - logError(device) << tr("inotify_add_watch for /dev/input returned with failure."); + qCCritical(PROJECTEUR_DEVICE_LOG).noquote() << QStringLiteral("inotify_add_watch for /dev/input returned with failure."); return false; } diff --git a/src/spotlight.h b/src/spotlight.h index d77c9eef..b6a72c07 100644 --- a/src/spotlight.h +++ b/src/spotlight.h @@ -53,8 +53,10 @@ class Spotlight : public QObject, public async::Async void deviceDisconnected(const DeviceId& id, const QString& name); void subDeviceConnected(const DeviceId& id, const QString& name, const QString& path); void subDeviceDisconnected(const DeviceId& id, const QString& name, const QString& path); + void deviceAccessError(const QString& name, const QString& path); void anySpotlightDeviceConnectedChanged(bool connected); void spotActiveChanged(bool isActive); + void slideNavigationPressed(); private: enum class ConnectionResult { CouldNotOpen, NotASpotlightDevice, Connected }; diff --git a/src/spotshapes.cc b/src/spotshapes.cc index 6cda9eb6..4bcab017 100644 --- a/src/spotshapes.cc +++ b/src/spotshapes.cc @@ -48,11 +48,7 @@ QSGNode* SpotShapeStar::updatePaintNode(QSGNode* oldNode, UpdatePaintNodeData* u // Set geometry const auto geometry = new QSGGeometry(QSGGeometry::defaultAttributes_Point2D(), vertexCount); - #if QT_VERSION >= 0x050800 - geometry->setDrawingMode(QSGGeometry::DrawTriangleFan); - #else - geometry->setDrawingMode(GL_TRIANGLE_FAN); - #endif + geometry->setDrawingMode(QSGGeometry::DrawTriangleFan); geometryNode->setGeometry(geometry); geometryNode->setFlag(QSGNode::OwnsGeometry, true); @@ -217,11 +213,7 @@ QSGNode* SpotShapeNGon::updatePaintNode(QSGNode* oldNode, UpdatePaintNodeData* u // Set geometry const auto geometry = new QSGGeometry(QSGGeometry::defaultAttributes_Point2D(), vertexCount); - #if QT_VERSION >= 0x050800 - geometry->setDrawingMode(QSGGeometry::DrawTriangleFan); - #else - geometry->setDrawingMode(GL_TRIANGLE_FAN); - #endif + geometry->setDrawingMode(QSGGeometry::DrawTriangleFan); geometryNode->setGeometry(geometry); geometryNode->setFlag(QSGNode::OwnsGeometry, true); diff --git a/src/virtualdevice.cc b/src/virtualdevice.cc index be163757..6f6455b1 100644 --- a/src/virtualdevice.cc +++ b/src/virtualdevice.cc @@ -3,7 +3,7 @@ #include "virtualdevice.h" -#include "logging.h" +#include "projecteur_virtual_device_debug.h" #include #include @@ -12,17 +12,11 @@ #include -LOGGING_CATEGORY(virtualdevice, "virtualdevice") - // KEY_MACRO1 is only defined in newer linux versions #ifndef KEY_MACRO1 #define KEY_MACRO1 0x290 #endif -namespace { - class VirtualDevice_ : public QObject {}; // for i18n and logging -} // end anonymous namespace - struct VirtualDevice::Token {}; // ------------------------------------------------------------------------------------------------- @@ -39,8 +33,8 @@ VirtualDevice::~VirtualDevice() { ioctl(m_uinpFd, UI_DEV_DESTROY); ::close(m_uinpFd); - logDebug(virtualdevice) - << VirtualDevice_::tr("uinput Device Closed (%1; %2)").arg(m_userName, m_deviceName); + qCDebug(PROJECTEUR_VIRTUAL_DEVICE_LOG).noquote() + << QStringLiteral("uinput Device Closed (%1; %2)").arg(m_userName).arg(m_deviceName); } } @@ -55,15 +49,15 @@ std::shared_ptr VirtualDevice::create(Type deviceType, { const QFileInfo fi(location); if (!fi.exists()) { - logWarn(virtualdevice) << VirtualDevice_::tr("File not found: %1").arg(location); - logWarn(virtualdevice) << VirtualDevice_::tr("Please check if uinput kernel module is loaded"); + qCWarning(PROJECTEUR_VIRTUAL_DEVICE_LOG).noquote() << QStringLiteral("File not found: %1").arg(location); + qCWarning(PROJECTEUR_VIRTUAL_DEVICE_LOG).noquote() << QStringLiteral("Please check if uinput kernel module is loaded"); return std::shared_ptr(); } const int fd = ::open(location, O_WRONLY | O_NDELAY); if (fd < 0) { - logWarn(virtualdevice) << VirtualDevice_::tr("Unable to open: %1").arg(location); - logWarn(virtualdevice) << VirtualDevice_::tr("Please check if current user has write access"); + qCWarning(PROJECTEUR_VIRTUAL_DEVICE_LOG).noquote() << QStringLiteral("Unable to open: %1").arg(location); + qCWarning(PROJECTEUR_VIRTUAL_DEVICE_LOG).noquote() << QStringLiteral("Please check if current user has write access"); return std::shared_ptr(); } @@ -111,16 +105,15 @@ std::shared_ptr VirtualDevice::create(Type deviceType, if ((bytesWritten != sizeof(uinp)) || (ioctl(fd, UI_DEV_CREATE))) { ::close(fd); - logWarn(virtualdevice) << VirtualDevice_::tr("Unable to create Virtual (UINPUT) device."); + qCWarning(PROJECTEUR_VIRTUAL_DEVICE_LOG).noquote() << QStringLiteral("Unable to create Virtual (UINPUT) device."); return std::unique_ptr(); } // Log the device name char sysfs_device_name[16]{}; ioctl(fd, UI_GET_SYSNAME(sizeof(sysfs_device_name)), sysfs_device_name); - logInfo(virtualdevice) << VirtualDevice_::tr("Created uinput device: %1") - .arg(QString("%1; /sys/devices/virtual/input/%2") - .arg(name, sysfs_device_name)); + qCInfo(PROJECTEUR_VIRTUAL_DEVICE_LOG).noquote() << QStringLiteral("Created uinput device: %1").arg(QString("%1; /sys/devices/virtual/input/%2") + .arg(name, sysfs_device_name)); return std::make_shared(Token{}, fd, name, sysfs_device_name); } @@ -133,7 +126,7 @@ void VirtualDevice::emitEvents(const struct input_event input_events[], size_t n if (const ssize_t sz = sizeof(input_event) * num) { const auto bytesWritten = write(m_uinpFd, input_events, sz); if (bytesWritten != sz) { - logError(virtualdevice) << VirtualDevice_::tr("Error while writing to virtual device."); + qCCritical(PROJECTEUR_VIRTUAL_DEVICE_LOG).noquote() << QStringLiteral("Error while writing to virtual device."); } } }