Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a02388c7fe | |||
| 8d81d8bcac | |||
| 5863b2f0ec | |||
| 191317eb1f | |||
| fcd10069ae | |||
| ed80fb0ea7 | |||
| ddea36c211 | |||
| 050ae25b69 | |||
| c39f3bda47 | |||
| f4ee5e4468 |
+7
-4
@@ -73,10 +73,13 @@ debug.log
|
|||||||
*.pot
|
*.pot
|
||||||
|
|
||||||
# Package artifacts
|
# Package artifacts
|
||||||
*.pkg.tar.zst
|
*.pkg.tar.*
|
||||||
distro-packages/*/cthulhu/
|
distro-packages/**/.SRCINFO
|
||||||
distro-packages/*/cthulhu-git/
|
distro-packages/**/*.log
|
||||||
distro-packages/*/pkg/
|
distro-packages/*/*/cthulhu/
|
||||||
|
distro-packages/*/*/cthulhu-git/
|
||||||
|
distro-packages/*/*/pkg/
|
||||||
|
distro-packages/*/*/src/
|
||||||
|
|
||||||
# Generated makefiles (should not be committed)
|
# Generated makefiles (should not be committed)
|
||||||
Makefile
|
Makefile
|
||||||
|
|||||||
@@ -125,5 +125,12 @@ Run the narrowest relevant checks first, then broaden testing based on the affec
|
|||||||
- In particular, update both Arch Linux `PKGBUILD` files and, when relevant, Slint's `cthulhu-info` and `README`. Review the Slackware and Slint build files for any corresponding build-dependency changes.
|
- In particular, update both Arch Linux `PKGBUILD` files and, when relevant, Slint's `cthulhu-info` and `README`. Review the Slackware and Slint build files for any corresponding build-dependency changes.
|
||||||
- Preserve the distinction between required, optional, and build-only dependencies so package builds install what Cthulhu actually needs without forcing optional features on every user.
|
- Preserve the distinction between required, optional, and build-only dependencies so package builds install what Cthulhu actually needs without forcing optional features on every user.
|
||||||
|
|
||||||
|
## Arch package verification
|
||||||
|
- After changing an Arch Linux `PKGBUILD`, build every affected recipe in a clean chroot before considering the packaging work complete. Use `pkgctl build --clean` from the `devtools` package; a normal `makepkg` or `yay` build is not a substitute for this check.
|
||||||
|
- Validate both stable and `-git` recipes when a shared packaging change affects both.
|
||||||
|
- Build dependency packages first. When a split helper depends on a locally built package that is unavailable in the configured repositories, pass that package artifact to the helper build with `pkgctl build --clean -I /path/to/package.pkg.tar.zst`.
|
||||||
|
- Run `bash -n`, `shellcheck`, and `makepkg --printsrcinfo` on each changed recipe before the clean-chroot build.
|
||||||
|
- Report which recipes completed clean-chroot builds. If sudo access, network access, or an unavailable dependency prevents a build, state that explicitly instead of describing the package as verified.
|
||||||
|
|
||||||
## Common Cthulhu agent mistakes
|
## Common Cthulhu agent mistakes
|
||||||
- Do not edit or diagnose a stale installed copy under `~/.local/...`; update the repo and rebuild with `./build-local.sh`.
|
- Do not edit or diagnose a stale installed copy under `~/.local/...`; update the repo and rebuild with `./build-local.sh`.
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
# shellcheck shell=bash disable=SC2034,SC2154,SC2164
|
# shellcheck shell=bash disable=SC2034,SC2154,SC2164
|
||||||
|
|
||||||
pkgbase=cthulhu-git
|
pkgbase=cthulhu-git
|
||||||
pkgname=(cthulhu-git cthulhu-wine-access-git)
|
pkgname=cthulhu-git
|
||||||
_pkgname=cthulhu
|
_pkgname=cthulhu
|
||||||
pkgver=2026.05.25.r423.g0576b6f
|
pkgver=2026.07.18.r465.g050ae25
|
||||||
pkgrel=2
|
pkgrel=1
|
||||||
pkgdesc="Desktop-agnostic screen reader with plugin system, forked from Orca"
|
pkgdesc="Desktop-agnostic screen reader with plugin system, forked from Orca"
|
||||||
url="https://git.stormux.org/storm/cthulhu"
|
url="https://git.stormux.org/storm/cthulhu"
|
||||||
arch=(x86_64 aarch64 armv7h)
|
arch=(x86_64 aarch64 armv7h)
|
||||||
@@ -67,16 +67,13 @@ optdepends=(
|
|||||||
'tesseract: OCR engine for text recognition'
|
'tesseract: OCR engine for text recognition'
|
||||||
)
|
)
|
||||||
optdepends_x86_64=(
|
optdepends_x86_64=(
|
||||||
|
# The helper is x86_64-only, but this package is architecture-independent.
|
||||||
'cthulhu-wine-access-git: Wine, Proton, NVDA Controller, and Tolk integration'
|
'cthulhu-wine-access-git: Wine, Proton, NVDA Controller, and Tolk integration'
|
||||||
)
|
)
|
||||||
makedepends=(
|
makedepends=(
|
||||||
git
|
git
|
||||||
meson
|
meson
|
||||||
)
|
)
|
||||||
makedepends_x86_64=(
|
|
||||||
cmake
|
|
||||||
wine
|
|
||||||
)
|
|
||||||
source=("${_pkgname}::git+https://git.stormux.org/storm/${_pkgname}.git#branch=master")
|
source=("${_pkgname}::git+https://git.stormux.org/storm/${_pkgname}.git#branch=master")
|
||||||
sha512sums=('SKIP')
|
sha512sums=('SKIP')
|
||||||
|
|
||||||
@@ -96,35 +93,19 @@ pkgver() {
|
|||||||
|
|
||||||
build() {
|
build() {
|
||||||
cd "${_pkgname}"
|
cd "${_pkgname}"
|
||||||
arch-meson _build
|
arch-meson _build -Dwine-access=disabled
|
||||||
meson compile -C _build
|
meson compile -C _build
|
||||||
}
|
}
|
||||||
|
|
||||||
package_cthulhu-git() {
|
package() {
|
||||||
arch=(any)
|
arch=(any)
|
||||||
provides=("cthulhu=${pkgver}")
|
provides=("cthulhu=${pkgver}")
|
||||||
conflicts=(cthulhu)
|
conflicts=(cthulhu)
|
||||||
cd "${_pkgname}"
|
cd "${_pkgname}"
|
||||||
meson install -C _build --destdir "$pkgdir"
|
meson install -C _build --destdir "$pkgdir"
|
||||||
|
|
||||||
rm -rf "$pkgdir/usr/libexec/cthulhu/wine"
|
|
||||||
|
|
||||||
# Remove icon cache - it will be generated by post-install hooks
|
# Remove icon cache - it will be generated by post-install hooks
|
||||||
rm -f "$pkgdir/usr/share/icons/hicolor/icon-theme.cache"
|
rm -f "$pkgdir/usr/share/icons/hicolor/icon-theme.cache"
|
||||||
}
|
}
|
||||||
|
|
||||||
package_cthulhu-wine-access-git() {
|
|
||||||
pkgdesc="Wine and Proton accessibility bridge for Cthulhu"
|
|
||||||
arch=(x86_64)
|
|
||||||
depends=("cthulhu-git=${pkgver}" glib2 wine)
|
|
||||||
provides=(cthulhu-wine-access)
|
|
||||||
conflicts=(cthulhu-wine-access)
|
|
||||||
|
|
||||||
cd "${_pkgname}"
|
|
||||||
install -Dm755 _build/cthulhu-wine-access.exe \
|
|
||||||
"$pkgdir/usr/libexec/cthulhu/wine/cthulhu-wine-access.exe"
|
|
||||||
install -Dm755 _build/cthulhu-wine-access.exe.so \
|
|
||||||
"$pkgdir/usr/libexec/cthulhu/wine/cthulhu-wine-access.exe.so"
|
|
||||||
}
|
|
||||||
|
|
||||||
# vim:set sw=2 sts=-1 et:
|
# vim:set sw=2 sts=-1 et:
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Maintainer: Storm Dragon <storm_dragon@stormux.org>
|
||||||
|
# shellcheck shell=bash disable=SC2034,SC2154,SC2164
|
||||||
|
|
||||||
|
pkgname=cthulhu-wine-access-git
|
||||||
|
_pkgname=cthulhu
|
||||||
|
pkgver=2026.07.18.r465.g050ae25
|
||||||
|
pkgrel=1
|
||||||
|
pkgdesc="Optional Wine and Proton accessibility bridge for Cthulhu"
|
||||||
|
url="https://git.stormux.org/storm/cthulhu"
|
||||||
|
arch=(x86_64)
|
||||||
|
license=(LGPL-2.1-or-later)
|
||||||
|
depends=(
|
||||||
|
"cthulhu-git=${pkgver}"
|
||||||
|
glib2
|
||||||
|
wine
|
||||||
|
)
|
||||||
|
makedepends=(
|
||||||
|
cmake
|
||||||
|
git
|
||||||
|
meson
|
||||||
|
)
|
||||||
|
provides=(cthulhu-wine-access)
|
||||||
|
conflicts=(cthulhu-wine-access)
|
||||||
|
source=("${_pkgname}::git+https://git.stormux.org/storm/${_pkgname}.git#branch=master")
|
||||||
|
sha512sums=('SKIP')
|
||||||
|
options=(!lto)
|
||||||
|
|
||||||
|
pkgver() {
|
||||||
|
cd "${_pkgname}"
|
||||||
|
local projectVersion revisionCount commitHash
|
||||||
|
|
||||||
|
projectVersion=$(sed -n "s/^[[:space:]]*version: '\([^']*\)'.*/\1/p" meson.build)
|
||||||
|
projectVersion=${projectVersion%-master}
|
||||||
|
projectVersion=${projectVersion//-/.}
|
||||||
|
[[ -n "${projectVersion}" ]] || return 1
|
||||||
|
|
||||||
|
revisionCount=$(git rev-list --count HEAD)
|
||||||
|
commitHash=$(git rev-parse --short=7 HEAD)
|
||||||
|
printf "%s.r%s.g%s\n" "${projectVersion}" "${revisionCount}" "${commitHash}"
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
cd "${_pkgname}"
|
||||||
|
arch-meson _build -Dwine-access=enabled
|
||||||
|
meson compile -C _build
|
||||||
|
}
|
||||||
|
|
||||||
|
package() {
|
||||||
|
cd "${_pkgname}"
|
||||||
|
install -Dm755 _build/cthulhu-wine-access.exe \
|
||||||
|
"$pkgdir/usr/libexec/cthulhu/wine/cthulhu-wine-access.exe"
|
||||||
|
install -Dm755 _build/cthulhu-wine-access.exe.so \
|
||||||
|
"$pkgdir/usr/libexec/cthulhu/wine/cthulhu-wine-access.exe.so"
|
||||||
|
}
|
||||||
|
|
||||||
|
# vim:set sw=2 sts=-1 et:
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Maintainer: Storm Dragon <storm_dragon@stormux.org>
|
||||||
|
# shellcheck shell=bash disable=SC2034,SC2154,SC2164
|
||||||
|
|
||||||
|
pkgname=cthulhu-wine-access
|
||||||
|
pkgver=2026.08.06
|
||||||
|
pkgrel=1
|
||||||
|
pkgdesc="Optional Wine and Proton accessibility bridge for Cthulhu"
|
||||||
|
url="https://git.stormux.org/storm/cthulhu"
|
||||||
|
arch=(x86_64)
|
||||||
|
license=(LGPL-2.1-or-later)
|
||||||
|
depends=(
|
||||||
|
"cthulhu=${pkgver}"
|
||||||
|
glib2
|
||||||
|
wine
|
||||||
|
)
|
||||||
|
makedepends=(
|
||||||
|
cmake
|
||||||
|
git
|
||||||
|
meson
|
||||||
|
)
|
||||||
|
source=("git+https://git.stormux.org/storm/cthulhu.git#tag=${pkgver}")
|
||||||
|
sha512sums=('SKIP')
|
||||||
|
options=(!lto)
|
||||||
|
|
||||||
|
build() {
|
||||||
|
cd cthulhu
|
||||||
|
arch-meson _build -Dwine-access=enabled
|
||||||
|
meson compile -C _build
|
||||||
|
}
|
||||||
|
|
||||||
|
package() {
|
||||||
|
cd cthulhu
|
||||||
|
install -Dm755 _build/cthulhu-wine-access.exe \
|
||||||
|
"$pkgdir/usr/libexec/cthulhu/wine/cthulhu-wine-access.exe"
|
||||||
|
install -Dm755 _build/cthulhu-wine-access.exe.so \
|
||||||
|
"$pkgdir/usr/libexec/cthulhu/wine/cthulhu-wine-access.exe.so"
|
||||||
|
}
|
||||||
|
|
||||||
|
# vim:set sw=2 sts=-1 et:
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
# shellcheck shell=bash disable=SC2034,SC2154,SC2164
|
# shellcheck shell=bash disable=SC2034,SC2154,SC2164
|
||||||
|
|
||||||
pkgbase=cthulhu
|
pkgbase=cthulhu
|
||||||
pkgname=(cthulhu cthulhu-wine-access)
|
pkgname=cthulhu
|
||||||
pkgver=2026.07.18
|
pkgver=2026.08.06
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Desktop-agnostic screen reader with plugin system, forked from Orca"
|
pkgdesc="Desktop-agnostic screen reader with plugin system, forked from Orca"
|
||||||
url="https://git.stormux.org/storm/cthulhu"
|
url="https://git.stormux.org/storm/cthulhu"
|
||||||
@@ -70,44 +70,24 @@ makedepends=(
|
|||||||
git
|
git
|
||||||
meson
|
meson
|
||||||
)
|
)
|
||||||
makedepends_x86_64=(
|
|
||||||
cmake
|
|
||||||
wine
|
|
||||||
)
|
|
||||||
source=("git+https://git.stormux.org/storm/cthulhu.git#tag=${pkgver}")
|
source=("git+https://git.stormux.org/storm/cthulhu.git#tag=${pkgver}")
|
||||||
sha512sums=('SKIP')
|
sha512sums=('SKIP')
|
||||||
|
|
||||||
build() {
|
build() {
|
||||||
cd cthulhu
|
cd cthulhu
|
||||||
arch-meson _build
|
arch-meson _build -Dwine-access=disabled
|
||||||
meson compile -C _build
|
meson compile -C _build
|
||||||
}
|
}
|
||||||
|
|
||||||
package_cthulhu() {
|
package() {
|
||||||
arch=(any)
|
arch=(any)
|
||||||
provides=("cthulhu=${pkgver}")
|
provides=("cthulhu=${pkgver}")
|
||||||
conflicts=(cthulhu)
|
conflicts=(cthulhu)
|
||||||
cd cthulhu
|
cd cthulhu
|
||||||
meson install -C _build --destdir "$pkgdir"
|
meson install -C _build --destdir "$pkgdir"
|
||||||
|
|
||||||
rm -rf "$pkgdir/usr/libexec/cthulhu/wine"
|
|
||||||
|
|
||||||
# Remove icon cache - it will be generated by post-install hooks
|
# Remove icon cache - it will be generated by post-install hooks
|
||||||
rm -f "$pkgdir/usr/share/icons/hicolor/icon-theme.cache"
|
rm -f "$pkgdir/usr/share/icons/hicolor/icon-theme.cache"
|
||||||
}
|
}
|
||||||
|
|
||||||
package_cthulhu-wine-access() {
|
|
||||||
pkgdesc="Wine and Proton accessibility bridge for Cthulhu"
|
|
||||||
arch=(x86_64)
|
|
||||||
depends=("cthulhu=${pkgver}" glib2 wine)
|
|
||||||
provides=(cthulhu-wine-access)
|
|
||||||
conflicts=(cthulhu-wine-access)
|
|
||||||
|
|
||||||
cd cthulhu
|
|
||||||
install -Dm755 _build/cthulhu-wine-access.exe \
|
|
||||||
"$pkgdir/usr/libexec/cthulhu/wine/cthulhu-wine-access.exe"
|
|
||||||
install -Dm755 _build/cthulhu-wine-access.exe.so \
|
|
||||||
"$pkgdir/usr/libexec/cthulhu/wine/cthulhu-wine-access.exe.so"
|
|
||||||
}
|
|
||||||
|
|
||||||
# vim:set sw=2 sts=-1 et:
|
# vim:set sw=2 sts=-1 et:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
cd "$(dirname "$0")" ; CWD=$(pwd)
|
cd "$(dirname "$0")" ; CWD=$(pwd)
|
||||||
|
|
||||||
PRGNAM=cthulhu
|
PRGNAM=cthulhu
|
||||||
VERSION=${VERSION:-2026.05.25}
|
VERSION=${VERSION:-2026.08.06}
|
||||||
BUILD=${BUILD:-1}
|
BUILD=${BUILD:-1}
|
||||||
TAG=storm
|
TAG=storm
|
||||||
PKGTYPE=txz
|
PKGTYPE=txz
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
PRGNAM="cthulhu"
|
PRGNAM="cthulhu"
|
||||||
VERSION="2026.05.25"
|
VERSION="2026.08.06"
|
||||||
HOMEPAGE="https://git.stormux.org/storm/cthulhu"
|
HOMEPAGE="https://git.stormux.org/storm/cthulhu"
|
||||||
DOWNLOAD="https://git.stormux.org/storm/cthulhu.git"
|
DOWNLOAD="https://git.stormux.org/storm/cthulhu.git"
|
||||||
MD5SUM="SKIP"
|
MD5SUM="SKIP"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
cd "$(dirname "$0")" ; CWD=$(pwd)
|
cd "$(dirname "$0")" ; CWD=$(pwd)
|
||||||
|
|
||||||
PRGNAM=cthulhu
|
PRGNAM=cthulhu
|
||||||
VERSION=${VERSION:-2026.05.25}
|
VERSION=${VERSION:-2026.08.06}
|
||||||
BUILD=${BUILD:-1}
|
BUILD=${BUILD:-1}
|
||||||
TAG=storm
|
TAG=storm
|
||||||
PKGTYPE=txz
|
PKGTYPE=txz
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
project('cthulhu',
|
project('cthulhu',
|
||||||
version: '2026.07.18',
|
version: '2026.08.06',
|
||||||
meson_version: '>= 1.0.0',
|
meson_version: '>= 1.0.0',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+13
-1
@@ -59,11 +59,12 @@ fi
|
|||||||
cthulhuVersionFile="${scriptDir}/src/cthulhu/cthulhuVersion.py"
|
cthulhuVersionFile="${scriptDir}/src/cthulhu/cthulhuVersion.py"
|
||||||
mesonFile="${scriptDir}/meson.build"
|
mesonFile="${scriptDir}/meson.build"
|
||||||
pkgbuildFile="${scriptDir}/distro-packages/Arch-Linux/cthulhu/PKGBUILD"
|
pkgbuildFile="${scriptDir}/distro-packages/Arch-Linux/cthulhu/PKGBUILD"
|
||||||
|
winePkgbuildFile="${scriptDir}/distro-packages/Arch-Linux/cthulhu-wine-access/PKGBUILD"
|
||||||
slackwareBuildFile="${scriptDir}/distro-packages/Slackware/cthulhu.SlackBuild"
|
slackwareBuildFile="${scriptDir}/distro-packages/Slackware/cthulhu.SlackBuild"
|
||||||
slintBuildFile="${scriptDir}/distro-packages/Slint/cthulhu.SlackBuild"
|
slintBuildFile="${scriptDir}/distro-packages/Slint/cthulhu.SlackBuild"
|
||||||
slintInfoFile="${scriptDir}/distro-packages/Slint/cthulhu-info"
|
slintInfoFile="${scriptDir}/distro-packages/Slint/cthulhu-info"
|
||||||
|
|
||||||
for path in "$cthulhuVersionFile" "$mesonFile" "$pkgbuildFile" "$slackwareBuildFile" "$slintBuildFile" "$slintInfoFile"; do
|
for path in "$cthulhuVersionFile" "$mesonFile" "$pkgbuildFile" "$winePkgbuildFile" "$slackwareBuildFile" "$slintBuildFile" "$slintInfoFile"; do
|
||||||
if [[ ! -f "$path" ]]; then
|
if [[ ! -f "$path" ]]; then
|
||||||
echo "Error: Missing file: $path" >&2
|
echo "Error: Missing file: $path" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -75,6 +76,8 @@ sed -i "s/^codeName = \".*\"/codeName = \"${codeNameValue}\"/" "$cthulhuVersionF
|
|||||||
sed -i "s/^ version: '.*',/ version: '${fullVersion}',/" "$mesonFile"
|
sed -i "s/^ version: '.*',/ version: '${fullVersion}',/" "$mesonFile"
|
||||||
sed -i "s/^pkgver=.*/pkgver=${pythonVersion}/" "$pkgbuildFile"
|
sed -i "s/^pkgver=.*/pkgver=${pythonVersion}/" "$pkgbuildFile"
|
||||||
sed -i "s/^pkgrel=.*/pkgrel=1/" "$pkgbuildFile"
|
sed -i "s/^pkgrel=.*/pkgrel=1/" "$pkgbuildFile"
|
||||||
|
sed -i "s/^pkgver=.*/pkgver=${pythonVersion}/" "$winePkgbuildFile"
|
||||||
|
sed -i "s/^pkgrel=.*/pkgrel=1/" "$winePkgbuildFile"
|
||||||
sed -i "s/^VERSION=\${VERSION:-.*}/VERSION=\${VERSION:-${pythonVersion}}/" "$slackwareBuildFile"
|
sed -i "s/^VERSION=\${VERSION:-.*}/VERSION=\${VERSION:-${pythonVersion}}/" "$slackwareBuildFile"
|
||||||
sed -i "s/^VERSION=\${VERSION:-.*}/VERSION=\${VERSION:-${pythonVersion}}/" "$slintBuildFile"
|
sed -i "s/^VERSION=\${VERSION:-.*}/VERSION=\${VERSION:-${pythonVersion}}/" "$slintBuildFile"
|
||||||
sed -i "s/^VERSION=\".*\"/VERSION=\"${pythonVersion}\"/" "$slintInfoFile"
|
sed -i "s/^VERSION=\".*\"/VERSION=\"${pythonVersion}\"/" "$slintInfoFile"
|
||||||
@@ -99,6 +102,14 @@ if ! rg -q "^pkgrel=1$" "$pkgbuildFile"; then
|
|||||||
echo "Error: Failed to reset pkgrel in ${pkgbuildFile}" >&2
|
echo "Error: Failed to reset pkgrel in ${pkgbuildFile}" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
if ! rg -q "^pkgver=${pythonVersion}$" "$winePkgbuildFile"; then
|
||||||
|
echo "Error: Failed to update ${winePkgbuildFile}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! rg -q "^pkgrel=1$" "$winePkgbuildFile"; then
|
||||||
|
echo "Error: Failed to reset pkgrel in ${winePkgbuildFile}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
if ! rg -q "^VERSION=\\\$\\{VERSION:-${pythonVersion}\\}$" "$slackwareBuildFile"; then
|
if ! rg -q "^VERSION=\\\$\\{VERSION:-${pythonVersion}\\}$" "$slackwareBuildFile"; then
|
||||||
echo "Error: Failed to update ${slackwareBuildFile}" >&2
|
echo "Error: Failed to update ${slackwareBuildFile}" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -116,6 +127,7 @@ echo "Updated version to ${fullVersion} in:" \
|
|||||||
"${cthulhuVersionFile}" \
|
"${cthulhuVersionFile}" \
|
||||||
"${mesonFile}" \
|
"${mesonFile}" \
|
||||||
"${pkgbuildFile}" \
|
"${pkgbuildFile}" \
|
||||||
|
"${winePkgbuildFile}" \
|
||||||
"${slackwareBuildFile}" \
|
"${slackwareBuildFile}" \
|
||||||
"${slintBuildFile}" \
|
"${slintBuildFile}" \
|
||||||
"${slintInfoFile}"
|
"${slintInfoFile}"
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from .compositor_state_types import (
|
|||||||
RESUME_ATSPI_CHURN,
|
RESUME_ATSPI_CHURN,
|
||||||
WORKSPACE_STATE_CHANGED,
|
WORKSPACE_STATE_CHANGED,
|
||||||
)
|
)
|
||||||
|
from .compositor_state_i3 import I3WorkspaceBackend
|
||||||
from .compositor_state_wayland import NullWorkspaceBackend, WaylandSharedProtocolsBackend
|
from .compositor_state_wayland import NullWorkspaceBackend, WaylandSharedProtocolsBackend
|
||||||
from .wnck_support import get_session_type
|
from .wnck_support import get_session_type
|
||||||
|
|
||||||
@@ -42,6 +43,7 @@ class CompositorStateAdapter:
|
|||||||
) -> None:
|
) -> None:
|
||||||
if workspace_backends is None:
|
if workspace_backends is None:
|
||||||
workspace_backends = [
|
workspace_backends = [
|
||||||
|
I3WorkspaceBackend(),
|
||||||
WaylandSharedProtocolsBackend(),
|
WaylandSharedProtocolsBackend(),
|
||||||
NullWorkspaceBackend(),
|
NullWorkspaceBackend(),
|
||||||
]
|
]
|
||||||
@@ -61,6 +63,15 @@ class CompositorStateAdapter:
|
|||||||
def get_snapshot(self) -> DesktopContextSnapshot:
|
def get_snapshot(self) -> DesktopContextSnapshot:
|
||||||
return self._snapshot
|
return self._snapshot
|
||||||
|
|
||||||
|
def refresh_workspace_context(self, reason: str) -> DesktopContextSnapshot:
|
||||||
|
"""Refreshes and returns the selected backend's current workspace context."""
|
||||||
|
|
||||||
|
backend = self._workspaceBackend
|
||||||
|
refreshContext = getattr(backend, "refresh_context", None)
|
||||||
|
if callable(refreshContext):
|
||||||
|
refreshContext(reason)
|
||||||
|
return self._snapshot
|
||||||
|
|
||||||
def get_session_type(self) -> str:
|
def get_session_type(self) -> str:
|
||||||
return get_session_type()
|
return get_session_type()
|
||||||
|
|
||||||
@@ -70,13 +81,15 @@ class CompositorStateAdapter:
|
|||||||
if not self._backend_is_available(backend):
|
if not self._backend_is_available(backend):
|
||||||
continue
|
continue
|
||||||
self._workspaceBackend = backend
|
self._workspaceBackend = backend
|
||||||
|
self._snapshot = DesktopContextSnapshot(
|
||||||
|
session_type=self.get_session_type(),
|
||||||
|
backend_name=self._backend_name(),
|
||||||
|
)
|
||||||
self._backend_activate(backend)
|
self._backend_activate(backend)
|
||||||
break
|
break
|
||||||
|
|
||||||
self._snapshot = DesktopContextSnapshot(
|
if self._workspaceBackend is None:
|
||||||
session_type=self.get_session_type(),
|
self._snapshot = DesktopContextSnapshot(session_type=self.get_session_type())
|
||||||
backend_name=self._backend_name(),
|
|
||||||
)
|
|
||||||
self.sync_accessible_context("activate")
|
self.sync_accessible_context("activate")
|
||||||
|
|
||||||
def deactivate(self) -> None:
|
def deactivate(self) -> None:
|
||||||
@@ -104,6 +117,9 @@ class CompositorStateAdapter:
|
|||||||
locus_of_focus_pid=locus_of_focus_snapshot["pid"],
|
locus_of_focus_pid=locus_of_focus_snapshot["pid"],
|
||||||
active_window_name=active_window_snapshot["name"],
|
active_window_name=active_window_snapshot["name"],
|
||||||
locus_of_focus_name=locus_of_focus_snapshot["name"],
|
locus_of_focus_name=locus_of_focus_snapshot["name"],
|
||||||
|
focused_window_id=workspaceSnapshot.focused_window_id,
|
||||||
|
focused_window_title=workspaceSnapshot.focused_window_title,
|
||||||
|
focused_workspace_empty=workspaceSnapshot.focused_workspace_empty,
|
||||||
)
|
)
|
||||||
|
|
||||||
previous_token = self._snapshot.active_window_token
|
previous_token = self._snapshot.active_window_token
|
||||||
@@ -145,27 +161,49 @@ class CompositorStateAdapter:
|
|||||||
signal_name: str,
|
signal_name: str,
|
||||||
workspace_ids: Optional[Iterable[str]] = None,
|
workspace_ids: Optional[Iterable[str]] = None,
|
||||||
reason: str = "",
|
reason: str = "",
|
||||||
|
payload: Optional[dict[str, Any]] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if workspace_ids is not None:
|
if workspace_ids is not None:
|
||||||
|
payload = payload or {}
|
||||||
|
focusedWorkspaceEmpty = payload.get(
|
||||||
|
"focused_workspace_empty",
|
||||||
|
self._snapshot.focused_workspace_empty,
|
||||||
|
)
|
||||||
|
clearAccessibleContext = focusedWorkspaceEmpty is True
|
||||||
snapshot = replace(
|
snapshot = replace(
|
||||||
self._snapshot,
|
self._snapshot,
|
||||||
active_workspace_ids=frozenset(str(workspaceId) for workspaceId in workspace_ids if workspaceId),
|
active_workspace_ids=frozenset(str(workspaceId) for workspaceId in workspace_ids if workspaceId),
|
||||||
workspace_transition_pending=signal_name == DESKTOP_TRANSITION_STARTED,
|
workspace_transition_pending=signal_name == DESKTOP_TRANSITION_STARTED,
|
||||||
|
active_window_token="" if clearAccessibleContext else self._snapshot.active_window_token,
|
||||||
|
locus_of_focus_token="" if clearAccessibleContext else self._snapshot.locus_of_focus_token,
|
||||||
|
active_window_pid=-1 if clearAccessibleContext else self._snapshot.active_window_pid,
|
||||||
|
locus_of_focus_pid=-1 if clearAccessibleContext else self._snapshot.locus_of_focus_pid,
|
||||||
|
active_window_name="" if clearAccessibleContext else self._snapshot.active_window_name,
|
||||||
|
locus_of_focus_name="" if clearAccessibleContext else self._snapshot.locus_of_focus_name,
|
||||||
|
focused_window_id=payload.get(
|
||||||
|
"focused_window_id",
|
||||||
|
self._snapshot.focused_window_id,
|
||||||
|
),
|
||||||
|
focused_window_title=payload.get(
|
||||||
|
"focused_window_title",
|
||||||
|
self._snapshot.focused_window_title,
|
||||||
|
),
|
||||||
|
focused_workspace_empty=focusedWorkspaceEmpty,
|
||||||
)
|
)
|
||||||
self._snapshot = snapshot
|
self._snapshot = snapshot
|
||||||
cthulhu_state.compositorSnapshot = snapshot
|
cthulhu_state.compositorSnapshot = snapshot
|
||||||
|
|
||||||
if signal_name == DESKTOP_TRANSITION_STARTED:
|
if signal_name == DESKTOP_TRANSITION_STARTED:
|
||||||
cthulhu_state.pauseAtspiChurn = True
|
cthulhu_state.pauseAtspiChurn = True
|
||||||
self._emit(signal_name, reason, snapshot)
|
self._emit(signal_name, reason, snapshot, payload)
|
||||||
self._emit(PAUSE_ATSPI_CHURN, reason, snapshot)
|
self._emit(PAUSE_ATSPI_CHURN, reason, snapshot, payload)
|
||||||
return
|
return
|
||||||
|
|
||||||
if signal_name == DESKTOP_TRANSITION_FINISHED:
|
if signal_name == DESKTOP_TRANSITION_FINISHED:
|
||||||
cthulhu_state.pauseAtspiChurn = False
|
cthulhu_state.pauseAtspiChurn = False
|
||||||
self._emit(signal_name, reason, snapshot)
|
self._emit(signal_name, reason, snapshot, payload)
|
||||||
self._emit(RESUME_ATSPI_CHURN, reason, snapshot)
|
self._emit(RESUME_ATSPI_CHURN, reason, snapshot, payload)
|
||||||
self._emit(FLUSH_STALE_ATSPI_EVENTS, reason, snapshot)
|
self._emit(FLUSH_STALE_ATSPI_EVENTS, reason, snapshot, payload)
|
||||||
return
|
return
|
||||||
|
|
||||||
if signal_name == PAUSE_ATSPI_CHURN:
|
if signal_name == PAUSE_ATSPI_CHURN:
|
||||||
@@ -173,7 +211,7 @@ class CompositorStateAdapter:
|
|||||||
elif signal_name == RESUME_ATSPI_CHURN:
|
elif signal_name == RESUME_ATSPI_CHURN:
|
||||||
cthulhu_state.pauseAtspiChurn = False
|
cthulhu_state.pauseAtspiChurn = False
|
||||||
|
|
||||||
self._emit(signal_name, reason, snapshot)
|
self._emit(signal_name, reason, snapshot, payload)
|
||||||
return
|
return
|
||||||
|
|
||||||
normalized = (signal_name or "").strip().lower()
|
normalized = (signal_name or "").strip().lower()
|
||||||
|
|||||||
@@ -0,0 +1,264 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
#
|
||||||
|
# Copyright (c) 2026 Stormux
|
||||||
|
#
|
||||||
|
# This library is free software; you can redistribute it and/or
|
||||||
|
# modify it under the terms of the GNU Lesser General Public
|
||||||
|
# License as published by the Free Software Foundation; either
|
||||||
|
# version 2.1 of the License, or (at your option) any later version.
|
||||||
|
|
||||||
|
"""Optional i3 IPC backend for authoritative X11 workspace and focus state."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import threading
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from gi.repository import GLib
|
||||||
|
|
||||||
|
from . import debug
|
||||||
|
from .compositor_state_types import (
|
||||||
|
DESKTOP_TRANSITION_FINISHED,
|
||||||
|
DESKTOP_TRANSITION_STARTED,
|
||||||
|
WORKSPACE_STATE_CHANGED,
|
||||||
|
)
|
||||||
|
from .wnck_support import get_session_type
|
||||||
|
|
||||||
|
|
||||||
|
class I3WorkspaceBackend:
|
||||||
|
"""Tracks the focused i3 workspace and its real foreground X11 window."""
|
||||||
|
|
||||||
|
name = "i3-ipc"
|
||||||
|
|
||||||
|
def __init__(self, *, i3ipc_module: Any = None) -> None:
|
||||||
|
self._i3ipc = i3ipc_module
|
||||||
|
self._connection: Any = None
|
||||||
|
self._emitSignal: Any = None
|
||||||
|
self._eventThread: Optional[threading.Thread] = None
|
||||||
|
self._active = False
|
||||||
|
self._lastContext: Optional[tuple[Any, ...]] = None
|
||||||
|
|
||||||
|
def is_available(self, session_type: str | None = None) -> bool:
|
||||||
|
effectiveSessionType = (session_type or get_session_type()).strip().lower()
|
||||||
|
if effectiveSessionType != "x11":
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self._i3ipc is None:
|
||||||
|
try:
|
||||||
|
self._i3ipc = importlib.import_module("i3ipc")
|
||||||
|
except ImportError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return self._ensure_connection()
|
||||||
|
|
||||||
|
def activate(self, emit_signal: Any = None) -> None:
|
||||||
|
self.deactivate()
|
||||||
|
self._emitSignal = emit_signal
|
||||||
|
if emit_signal is None or not self.is_available():
|
||||||
|
return
|
||||||
|
|
||||||
|
self._active = True
|
||||||
|
self._subscribe()
|
||||||
|
self._refresh_context("activate", transition=False)
|
||||||
|
self._eventThread = threading.Thread(
|
||||||
|
target=self._run_event_loop,
|
||||||
|
name="cthulhu-i3-ipc",
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
self._eventThread.start()
|
||||||
|
|
||||||
|
def deactivate(self, emit_signal: Any = None) -> None:
|
||||||
|
self._active = False
|
||||||
|
connection = self._connection
|
||||||
|
if connection is not None:
|
||||||
|
quitLoop = getattr(connection, "main_quit", None)
|
||||||
|
if callable(quitLoop):
|
||||||
|
try:
|
||||||
|
quitLoop()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
commandSocket = getattr(connection, "_cmd_socket", None)
|
||||||
|
if commandSocket is not None:
|
||||||
|
try:
|
||||||
|
commandSocket.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self._connection = None
|
||||||
|
self._eventThread = None
|
||||||
|
self._emitSignal = None
|
||||||
|
self._lastContext = None
|
||||||
|
|
||||||
|
def refresh_context(self, reason: str) -> None:
|
||||||
|
"""Refreshes the cached i3 focus context without emitting a transition."""
|
||||||
|
|
||||||
|
self._refresh_context(reason, transition=False)
|
||||||
|
|
||||||
|
def _ensure_connection(self) -> bool:
|
||||||
|
if self._connection is not None:
|
||||||
|
return True
|
||||||
|
if self._i3ipc is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
connectionClass = getattr(self._i3ipc, "Connection", None)
|
||||||
|
if not callable(connectionClass):
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
self._connection = connectionClass(auto_reconnect=True)
|
||||||
|
except TypeError:
|
||||||
|
self._connection = connectionClass()
|
||||||
|
self._connection.get_workspaces()
|
||||||
|
except Exception as error:
|
||||||
|
self._connection = None
|
||||||
|
msg = f"COMPOSITOR STATE: i3 IPC unavailable: {error}"
|
||||||
|
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _subscribe(self) -> None:
|
||||||
|
if self._connection is None or self._i3ipc is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
onEvent = getattr(self._connection, "on", None)
|
||||||
|
eventType = getattr(self._i3ipc, "Event", None)
|
||||||
|
if not callable(onEvent) or eventType is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
onEvent(eventType.WORKSPACE, self._handle_ipc_event)
|
||||||
|
onEvent(eventType.WINDOW, self._handle_ipc_event)
|
||||||
|
|
||||||
|
def _run_event_loop(self) -> None:
|
||||||
|
connection = self._connection
|
||||||
|
runLoop = getattr(connection, "main", None)
|
||||||
|
if not callable(runLoop):
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
runLoop()
|
||||||
|
except Exception as error:
|
||||||
|
if self._active:
|
||||||
|
msg = f"COMPOSITOR STATE: i3 IPC event loop stopped: {error}"
|
||||||
|
debug.printMessage(debug.LEVEL_WARNING, msg, True)
|
||||||
|
|
||||||
|
def _handle_ipc_event(self, _connection: Any, event: Any) -> None:
|
||||||
|
if not self._active:
|
||||||
|
return
|
||||||
|
|
||||||
|
change = str(getattr(event, "change", "") or "changed")
|
||||||
|
GLib.idle_add(self._refresh_context, f"i3-{change}", True)
|
||||||
|
|
||||||
|
def _query_context(self) -> tuple[set[str], dict[str, Any]]:
|
||||||
|
if self._connection is None:
|
||||||
|
raise RuntimeError("i3 IPC is not connected")
|
||||||
|
|
||||||
|
workspaceReplies = self._connection.get_workspaces()
|
||||||
|
focusedReply = next(
|
||||||
|
(workspace for workspace in workspaceReplies if bool(getattr(workspace, "focused", False))),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if focusedReply is None:
|
||||||
|
raise RuntimeError("i3 did not report a focused workspace")
|
||||||
|
|
||||||
|
workspaceName = str(getattr(focusedReply, "name", "") or "").strip()
|
||||||
|
if not workspaceName:
|
||||||
|
raise RuntimeError("i3 focused workspace has no name")
|
||||||
|
|
||||||
|
tree = self._connection.get_tree()
|
||||||
|
focusedWorkspace = next(
|
||||||
|
(
|
||||||
|
workspace
|
||||||
|
for workspace in tree.workspaces()
|
||||||
|
if str(getattr(workspace, "name", "") or "") == workspaceName
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if focusedWorkspace is None:
|
||||||
|
raise RuntimeError(f"i3 tree is missing focused workspace {workspaceName}")
|
||||||
|
|
||||||
|
windowNodes = [
|
||||||
|
node
|
||||||
|
for node in focusedWorkspace
|
||||||
|
if self._positive_window_id(getattr(node, "window", None)) is not None
|
||||||
|
]
|
||||||
|
focusedNode = tree.find_focused()
|
||||||
|
focusedWindowId: Optional[int] = None
|
||||||
|
focusedWindowTitle = ""
|
||||||
|
if focusedNode is not None and focusedNode.workspace() is focusedWorkspace:
|
||||||
|
focusedWindowId = self._positive_window_id(getattr(focusedNode, "window", None))
|
||||||
|
if focusedWindowId is not None:
|
||||||
|
focusedWindowTitle = str(
|
||||||
|
getattr(focusedNode, "window_title", None)
|
||||||
|
or getattr(focusedNode, "name", None)
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"focused_window_id": focusedWindowId,
|
||||||
|
"focused_window_title": focusedWindowTitle,
|
||||||
|
"focused_workspace_empty": not windowNodes,
|
||||||
|
}
|
||||||
|
return {workspaceName}, payload
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _positive_window_id(value: Any) -> Optional[int]:
|
||||||
|
try:
|
||||||
|
windowId = int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return windowId if windowId > 0 else None
|
||||||
|
|
||||||
|
def _refresh_context(self, reason: str, transition: bool = True) -> bool:
|
||||||
|
if not self._active:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
workspaceIds, payload = self._query_context()
|
||||||
|
except Exception as error:
|
||||||
|
msg = f"COMPOSITOR STATE: Could not query i3 context: {error}"
|
||||||
|
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
||||||
|
unknownPayload = {
|
||||||
|
"focused_window_id": None,
|
||||||
|
"focused_window_title": "",
|
||||||
|
"focused_workspace_empty": None,
|
||||||
|
}
|
||||||
|
self._lastContext = None
|
||||||
|
self._emit_signal(
|
||||||
|
WORKSPACE_STATE_CHANGED,
|
||||||
|
set(),
|
||||||
|
f"{reason}: i3-context-unknown",
|
||||||
|
unknownPayload,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
context = (
|
||||||
|
frozenset(workspaceIds),
|
||||||
|
payload["focused_window_id"],
|
||||||
|
payload["focused_window_title"],
|
||||||
|
payload["focused_workspace_empty"],
|
||||||
|
)
|
||||||
|
if context == self._lastContext:
|
||||||
|
return False
|
||||||
|
self._lastContext = context
|
||||||
|
|
||||||
|
if transition:
|
||||||
|
self._emit_signal(DESKTOP_TRANSITION_STARTED, workspaceIds, reason, payload)
|
||||||
|
self._emit_signal(WORKSPACE_STATE_CHANGED, workspaceIds, reason, payload)
|
||||||
|
if transition:
|
||||||
|
self._emit_signal(DESKTOP_TRANSITION_FINISHED, workspaceIds, reason, payload)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _emit_signal(
|
||||||
|
self,
|
||||||
|
signalType: str,
|
||||||
|
workspaceIds: set[str],
|
||||||
|
reason: str,
|
||||||
|
payload: Mapping[str, Any],
|
||||||
|
) -> None:
|
||||||
|
if callable(self._emitSignal):
|
||||||
|
self._emitSignal(signalType, workspaceIds, reason, dict(payload))
|
||||||
@@ -38,6 +38,9 @@ class DesktopContextSnapshot:
|
|||||||
locus_of_focus_pid: int = -1
|
locus_of_focus_pid: int = -1
|
||||||
active_window_name: str = ""
|
active_window_name: str = ""
|
||||||
locus_of_focus_name: str = ""
|
locus_of_focus_name: str = ""
|
||||||
|
focused_window_id: Optional[int] = None
|
||||||
|
focused_window_title: str = ""
|
||||||
|
focused_workspace_empty: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
|
|||||||
@@ -23,5 +23,5 @@
|
|||||||
# Forked from Orca screen reader.
|
# Forked from Orca screen reader.
|
||||||
# Cthulhu project: https://git.stormux.org/storm/cthulhu
|
# Cthulhu project: https://git.stormux.org/storm/cthulhu
|
||||||
|
|
||||||
version = "2026.07.19"
|
version = "2026.08.06"
|
||||||
codeName = "wine-access"
|
codeName = "master"
|
||||||
|
|||||||
+112
-28
@@ -60,6 +60,7 @@ from .compositor_state_types import (
|
|||||||
PAUSE_ATSPI_CHURN,
|
PAUSE_ATSPI_CHURN,
|
||||||
PRIORITIZE_FOCUS,
|
PRIORITIZE_FOCUS,
|
||||||
RESUME_ATSPI_CHURN,
|
RESUME_ATSPI_CHURN,
|
||||||
|
WORKSPACE_STATE_CHANGED,
|
||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -113,6 +114,8 @@ class EventManager:
|
|||||||
self._compositorStateAdapter: Optional[Any] = None
|
self._compositorStateAdapter: Optional[Any] = None
|
||||||
self._churnSuppressed: bool = cthulhu_state.pauseAtspiChurn
|
self._churnSuppressed: bool = cthulhu_state.pauseAtspiChurn
|
||||||
self._prioritizedContextToken: Optional[str] = cthulhu_state.prioritizedDesktopContextToken
|
self._prioritizedContextToken: Optional[str] = cthulhu_state.prioritizedDesktopContextToken
|
||||||
|
self._desktopContextConfirmedEmpty: bool = False
|
||||||
|
self._flushingStaleAtspiEvents: bool = False
|
||||||
self._relevanceBurstWindow: float = 0.15
|
self._relevanceBurstWindow: float = 0.15
|
||||||
self._relevanceBurstHistory: Dict[Tuple[str, str, str], float] = {}
|
self._relevanceBurstHistory: Dict[Tuple[str, str, str], float] = {}
|
||||||
|
|
||||||
@@ -171,6 +174,7 @@ class EventManager:
|
|||||||
|
|
||||||
debug.printMessage(debug.LEVEL_INFO, 'EVENT MANAGER: Activating keyboard handling', True)
|
debug.printMessage(debug.LEVEL_INFO, 'EVENT MANAGER: Activating keyboard handling', True)
|
||||||
self._inputEventManager = input_event_manager.get_manager()
|
self._inputEventManager = input_event_manager.get_manager()
|
||||||
|
self._inputEventManager.set_compositor_state_adapter(self._compositorStateAdapter)
|
||||||
self._inputEventManager.start_key_watcher()
|
self._inputEventManager.start_key_watcher()
|
||||||
cthulhu_state.device = self._inputEventManager._device
|
cthulhu_state.device = self._inputEventManager._device
|
||||||
self._keyHandlingActive = True
|
self._keyHandlingActive = True
|
||||||
@@ -274,6 +278,8 @@ class EventManager:
|
|||||||
self._compositorStateAdapter.remove_listener(self._handle_compositor_signal)
|
self._compositorStateAdapter.remove_listener(self._handle_compositor_signal)
|
||||||
|
|
||||||
self._compositorStateAdapter = adapter
|
self._compositorStateAdapter = adapter
|
||||||
|
if self._inputEventManager is not None:
|
||||||
|
self._inputEventManager.set_compositor_state_adapter(adapter)
|
||||||
if adapter is not None and hasattr(adapter, "add_listener"):
|
if adapter is not None and hasattr(adapter, "add_listener"):
|
||||||
adapter.add_listener(self._handle_compositor_signal)
|
adapter.add_listener(self._handle_compositor_signal)
|
||||||
|
|
||||||
@@ -292,6 +298,9 @@ class EventManager:
|
|||||||
if not contextToken:
|
if not contextToken:
|
||||||
contextToken = snapshotToken or None
|
contextToken = snapshotToken or None
|
||||||
|
|
||||||
|
if signalType in (WORKSPACE_STATE_CHANGED, DESKTOP_TRANSITION_FINISHED):
|
||||||
|
self._update_i3_workspace_context(snapshot)
|
||||||
|
|
||||||
if signalType in (DESKTOP_FOCUS_CONTEXT_CHANGED, PRIORITIZE_FOCUS):
|
if signalType in (DESKTOP_FOCUS_CONTEXT_CHANGED, PRIORITIZE_FOCUS):
|
||||||
self._prioritizedContextToken = contextToken
|
self._prioritizedContextToken = contextToken
|
||||||
cthulhu_state.prioritizedDesktopContextToken = contextToken
|
cthulhu_state.prioritizedDesktopContextToken = contextToken
|
||||||
@@ -318,6 +327,43 @@ class EventManager:
|
|||||||
if signalType == FLUSH_STALE_ATSPI_EVENTS:
|
if signalType == FLUSH_STALE_ATSPI_EVENTS:
|
||||||
self._flush_stale_atspi_events()
|
self._flush_stale_atspi_events()
|
||||||
|
|
||||||
|
def _update_i3_workspace_context(self, snapshot: Any) -> None:
|
||||||
|
"""Clears stale accessible state when i3 confirms a blank workspace."""
|
||||||
|
|
||||||
|
if snapshot is None or snapshot.backend_name != "i3-ipc":
|
||||||
|
return
|
||||||
|
|
||||||
|
workspaceEmpty = snapshot.focused_workspace_empty
|
||||||
|
if workspaceEmpty is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not workspaceEmpty:
|
||||||
|
if self._desktopContextConfirmedEmpty:
|
||||||
|
self._desktopContextConfirmedEmpty = False
|
||||||
|
GLib.idle_add(self._sync_focus_on_startup)
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._desktopContextConfirmedEmpty:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._desktopContextConfirmedEmpty = True
|
||||||
|
activeScript = self.app.scriptManager.get_active_script()
|
||||||
|
if activeScript is not None:
|
||||||
|
activeScript.presentationInterrupt()
|
||||||
|
|
||||||
|
manager = focus_manager.get_manager()
|
||||||
|
if manager is not None:
|
||||||
|
manager.clear_state("confirmed empty i3 workspace")
|
||||||
|
cthulhu_state.pendingSelfHostedFocus = None
|
||||||
|
self._prioritizedContextToken = None
|
||||||
|
cthulhu_state.prioritizedDesktopContextToken = None
|
||||||
|
|
||||||
|
defaultScript = self.app.scriptManager.get_default_script()
|
||||||
|
self.app.scriptManager.set_active_script(
|
||||||
|
defaultScript,
|
||||||
|
"focus: confirmed-empty-i3-workspace",
|
||||||
|
)
|
||||||
|
|
||||||
def ignoreEventTypes(self, eventTypeList: List[str]) -> None:
|
def ignoreEventTypes(self, eventTypeList: List[str]) -> None:
|
||||||
for eventType in eventTypeList:
|
for eventType in eventTypeList:
|
||||||
if eventType not in self._ignoredEvents:
|
if eventType not in self._ignoredEvents:
|
||||||
@@ -882,32 +928,32 @@ class EventManager:
|
|||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _addToQueue(self, event: Any, asyncMode: bool) -> None:
|
def _addToQueue(self, event: Any, asyncMode: bool) -> bool:
|
||||||
debugging = debug.debugEventQueue
|
debugging = debug.debugEventQueue
|
||||||
if debugging:
|
if debugging:
|
||||||
debug.printMessage(debug.LEVEL_ALL, " acquiring lock...")
|
debug.printMessage(debug.LEVEL_ALL, " acquiring lock...")
|
||||||
self._gidleLock.acquire()
|
|
||||||
|
|
||||||
if debugging:
|
with self._gidleLock:
|
||||||
debug.printMessage(debug.LEVEL_ALL, " ...acquired")
|
effectiveAsyncMode = asyncMode or self._flushingStaleAtspiEvents
|
||||||
debug.printMessage(debug.LEVEL_ALL, " calling queue.put...")
|
if debugging:
|
||||||
debug.printMessage(debug.LEVEL_ALL, " (full=%s)" \
|
debug.printMessage(debug.LEVEL_ALL, " ...acquired")
|
||||||
% self._eventQueue.full())
|
debug.printMessage(debug.LEVEL_ALL, " calling queue.put...")
|
||||||
|
debug.printMessage(debug.LEVEL_ALL, " (full=%s)" \
|
||||||
|
% self._eventQueue.full())
|
||||||
|
|
||||||
self._eventQueue.put(event)
|
self._eventQueue.put(event)
|
||||||
if debugging:
|
if debugging:
|
||||||
debug.printMessage(debug.LEVEL_ALL, " ...put complete")
|
debug.printMessage(debug.LEVEL_ALL, " ...put complete")
|
||||||
|
|
||||||
if asyncMode and not self._gidleId:
|
if effectiveAsyncMode and not self._gidleId:
|
||||||
if self._gilSleepTime:
|
if self._gilSleepTime:
|
||||||
time.sleep(self._gilSleepTime)
|
time.sleep(self._gilSleepTime)
|
||||||
self._gidleId = GLib.idle_add(self._dequeue)
|
self._gidleId = GLib.idle_add(self._dequeue)
|
||||||
|
|
||||||
if debugging:
|
|
||||||
debug.printMessage(debug.LEVEL_ALL, " releasing lock...")
|
|
||||||
self._gidleLock.release()
|
|
||||||
if debug.debugEventQueue:
|
if debug.debugEventQueue:
|
||||||
|
debug.printMessage(debug.LEVEL_ALL, " releasing lock...")
|
||||||
debug.printMessage(debug.LEVEL_ALL, " ...released")
|
debug.printMessage(debug.LEVEL_ALL, " ...released")
|
||||||
|
return effectiveAsyncMode
|
||||||
|
|
||||||
def _queuePrintln(self, e: Any, isEnqueue: bool = True, isPrune: Optional[bool] = None) -> None:
|
def _queuePrintln(self, e: Any, isEnqueue: bool = True, isPrune: Optional[bool] = None) -> None:
|
||||||
"""Convenience method to output queue-related debugging info."""
|
"""Convenience method to output queue-related debugging info."""
|
||||||
@@ -1076,7 +1122,7 @@ class EventManager:
|
|||||||
script = cthulhu.cthulhuApp.scriptManager.get_script(AXObject.get_application(e.source), e.source)
|
script = cthulhu.cthulhuApp.scriptManager.get_script(AXObject.get_application(e.source), e.source)
|
||||||
script.eventCache[e.type] = (e, time.time())
|
script.eventCache[e.type] = (e, time.time())
|
||||||
|
|
||||||
self._addToQueue(e, asyncMode)
|
asyncMode = self._addToQueue(e, asyncMode)
|
||||||
if not asyncMode:
|
if not asyncMode:
|
||||||
self._dequeue()
|
self._dequeue()
|
||||||
|
|
||||||
@@ -1685,26 +1731,58 @@ class EventManager:
|
|||||||
def _flush_stale_atspi_events(self) -> None:
|
def _flush_stale_atspi_events(self) -> None:
|
||||||
"""Drops queued events that no longer match the compositor context."""
|
"""Drops queued events that no longer match the compositor context."""
|
||||||
|
|
||||||
self._gidleLock.acquire()
|
with self._gidleLock:
|
||||||
try:
|
self._flushingStaleAtspiEvents = True
|
||||||
originalQueue = self._eventQueue
|
originalQueue = self._eventQueue
|
||||||
newQueue: queue.Queue[Any] = queue.Queue(0)
|
self._eventQueue = queue.Queue(0)
|
||||||
|
|
||||||
|
retainedEvents: list[Any] = []
|
||||||
|
try:
|
||||||
while not originalQueue.empty():
|
while not originalQueue.empty():
|
||||||
try:
|
try:
|
||||||
event = originalQueue.get_nowait()
|
event = originalQueue.get_nowait()
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
break
|
break
|
||||||
|
|
||||||
if self._event_is_from_stale_context(event) and not self._should_preserve_during_suppression(event):
|
# Accessible property calls can dispatch nested AT-SPI events.
|
||||||
|
# Keep them outside _gidleLock so a nested enqueue cannot deadlock.
|
||||||
|
try:
|
||||||
|
isStale = self._event_is_from_stale_context(event)
|
||||||
|
shouldPreserve = (
|
||||||
|
isStale
|
||||||
|
and self._should_preserve_during_suppression(event)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
retainedEvents.append(event)
|
||||||
|
raise
|
||||||
|
|
||||||
|
if isStale and not shouldPreserve:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
newQueue.put(event)
|
retainedEvents.append(event)
|
||||||
|
|
||||||
self._eventQueue = newQueue
|
|
||||||
if self._asyncMode and not self._eventQueue.empty() and not self._gidleId:
|
|
||||||
self._gidleId = GLib.idle_add(self._dequeue)
|
|
||||||
finally:
|
finally:
|
||||||
self._gidleLock.release()
|
while not originalQueue.empty():
|
||||||
|
try:
|
||||||
|
retainedEvents.append(originalQueue.get_nowait())
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
|
||||||
|
with self._gidleLock:
|
||||||
|
eventsQueuedDuringFlush = self._eventQueue
|
||||||
|
mergedQueue: queue.Queue[Any] = queue.Queue(0)
|
||||||
|
for event in retainedEvents:
|
||||||
|
mergedQueue.put(event)
|
||||||
|
|
||||||
|
while not eventsQueuedDuringFlush.empty():
|
||||||
|
try:
|
||||||
|
mergedQueue.put(eventsQueuedDuringFlush.get_nowait())
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
|
||||||
|
self._eventQueue = mergedQueue
|
||||||
|
self._flushingStaleAtspiEvents = False
|
||||||
|
if self._asyncMode and not self._eventQueue.empty() and not self._gidleId:
|
||||||
|
self._gidleId = GLib.idle_add(self._dequeue)
|
||||||
|
|
||||||
def _inFlood(self) -> bool:
|
def _inFlood(self) -> bool:
|
||||||
size = self._eventQueue.qsize()
|
size = self._eventQueue.qsize()
|
||||||
@@ -1771,6 +1849,12 @@ class EventManager:
|
|||||||
debug.printObjectEvent(debug.LEVEL_INFO, event, timestamp=True)
|
debug.printObjectEvent(debug.LEVEL_INFO, event, timestamp=True)
|
||||||
eType = event.type
|
eType = event.type
|
||||||
|
|
||||||
|
if self._desktopContextConfirmedEmpty \
|
||||||
|
and not self._is_notification_event_during_xterm_handoff(event):
|
||||||
|
msg = "EVENT MANAGER: Ignoring AT-SPI event on confirmed empty i3 workspace."
|
||||||
|
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
||||||
|
return
|
||||||
|
|
||||||
refreshXtermHandoff = self._event_refreshes_xterm_handoff(event)
|
refreshXtermHandoff = self._event_refreshes_xterm_handoff(event)
|
||||||
isNotification = self._is_notification_event_during_xterm_handoff(event)
|
isNotification = self._is_notification_event_during_xterm_handoff(event)
|
||||||
suppressForXterm = not isNotification and self._inputEventManager is not None and (
|
suppressForXterm = not isNotification and self._inputEventManager is not None and (
|
||||||
|
|||||||
@@ -125,12 +125,21 @@ class InputEventManager:
|
|||||||
self._netActiveWindowAtom = None
|
self._netActiveWindowAtom = None
|
||||||
self._x11AnyPropertyType = None
|
self._x11AnyPropertyType = None
|
||||||
self._didAttemptX11Display: bool = False
|
self._didAttemptX11Display: bool = False
|
||||||
|
self._lastEwmhReadWindowId: Optional[int] = None
|
||||||
self._lastEwmhActiveWindowId: Optional[int] = None
|
self._lastEwmhActiveWindowId: Optional[int] = None
|
||||||
self._lastEwmhXtermMatch: Optional[bool] = None
|
self._lastEwmhXtermMatch: Optional[bool] = None
|
||||||
self._scriptWithSuspendedGrabsForXterm = None
|
self._scriptWithSuspendedGrabsForXterm = None
|
||||||
self._xtermFocusScreen = None
|
self._xtermFocusScreen = None
|
||||||
self._xtermFocusHandlerId: int = 0
|
self._xtermFocusHandlerId: int = 0
|
||||||
self._xtermRecoverySourceId: int = 0
|
self._xtermRecoverySourceId: int = 0
|
||||||
|
self._xtermRecoveryPollCount: int = 0
|
||||||
|
self._xtermHandoffHistory: List[str] = []
|
||||||
|
self._compositorStateAdapter: Optional[Any] = None
|
||||||
|
|
||||||
|
def set_compositor_state_adapter(self, adapter: Optional[Any]) -> None:
|
||||||
|
"""Stores the compositor adapter used to refresh XTerm handoff state."""
|
||||||
|
|
||||||
|
self._compositorStateAdapter = adapter
|
||||||
|
|
||||||
def activate_device(self) -> Atspi.Device:
|
def activate_device(self) -> Atspi.Device:
|
||||||
"""Creates and returns the AT-SPI device used by this manager."""
|
"""Creates and returns the AT-SPI device used by this manager."""
|
||||||
@@ -229,6 +238,14 @@ class InputEventManager:
|
|||||||
"suspended_xterm_script_present": self._scriptWithSuspendedGrabsForXterm is not None,
|
"suspended_xterm_script_present": self._scriptWithSuspendedGrabsForXterm is not None,
|
||||||
"xterm_focus_monitor_active": self._xtermFocusHandlerId != 0,
|
"xterm_focus_monitor_active": self._xtermFocusHandlerId != 0,
|
||||||
"xterm_recovery_timer_active": self._xtermRecoverySourceId != 0,
|
"xterm_recovery_timer_active": self._xtermRecoverySourceId != 0,
|
||||||
|
"xterm_recovery_source_present": self._xterm_recovery_source_is_present(),
|
||||||
|
"xterm_recovery_poll_count": self._xtermRecoveryPollCount,
|
||||||
|
"xterm_handoff_history": " | ".join(self._xtermHandoffHistory),
|
||||||
|
"last_ewmh_read_window_id": (
|
||||||
|
hex(self._lastEwmhReadWindowId)
|
||||||
|
if self._lastEwmhReadWindowId is not None
|
||||||
|
else ""
|
||||||
|
),
|
||||||
"last_ewmh_active_window_id": (
|
"last_ewmh_active_window_id": (
|
||||||
hex(self._lastEwmhActiveWindowId)
|
hex(self._lastEwmhActiveWindowId)
|
||||||
if self._lastEwmhActiveWindowId is not None
|
if self._lastEwmhActiveWindowId is not None
|
||||||
@@ -578,6 +595,31 @@ class InputEventManager:
|
|||||||
def _get_ewmh_active_x11_window_id(self) -> Optional[int]:
|
def _get_ewmh_active_x11_window_id(self) -> Optional[int]:
|
||||||
"""Returns _NET_ACTIVE_WINDOW from the X11 root window when available."""
|
"""Returns _NET_ACTIVE_WINDOW from the X11 root window when available."""
|
||||||
|
|
||||||
|
if not self._ensure_x11_display():
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
propertyData = self._x11Root.get_full_property(
|
||||||
|
self._netActiveWindowAtom,
|
||||||
|
self._x11AnyPropertyType,
|
||||||
|
)
|
||||||
|
if propertyData is None or not propertyData.value:
|
||||||
|
return None
|
||||||
|
windowId = int(propertyData.value[0])
|
||||||
|
return windowId if windowId > 0 else None
|
||||||
|
except Exception as error:
|
||||||
|
msg = f"INPUT EVENT MANAGER: Could not read EWMH active window: {error}"
|
||||||
|
debug.print_message(debug.LEVEL_INFO, msg, True)
|
||||||
|
self._close_x11_display()
|
||||||
|
self._didAttemptX11Display = False
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _ensure_x11_display(self) -> bool:
|
||||||
|
"""Initializes the optional exact-X11 metadata connection."""
|
||||||
|
|
||||||
|
if self._x11Display is not None:
|
||||||
|
return True
|
||||||
|
|
||||||
if not self._didAttemptX11Display:
|
if not self._didAttemptX11Display:
|
||||||
self._didAttemptX11Display = True
|
self._didAttemptX11Display = True
|
||||||
try:
|
try:
|
||||||
@@ -593,22 +635,11 @@ class InputEventManager:
|
|||||||
debug.print_message(debug.LEVEL_INFO, msg, True)
|
debug.print_message(debug.LEVEL_INFO, msg, True)
|
||||||
self._close_x11_display()
|
self._close_x11_display()
|
||||||
|
|
||||||
if self._x11Root is None or self._netActiveWindowAtom is None:
|
return (
|
||||||
return None
|
self._x11Display is not None
|
||||||
|
and self._x11Root is not None
|
||||||
try:
|
and self._netActiveWindowAtom is not None
|
||||||
propertyData = self._x11Root.get_full_property(
|
)
|
||||||
self._netActiveWindowAtom,
|
|
||||||
self._x11AnyPropertyType,
|
|
||||||
)
|
|
||||||
if propertyData is None or not propertyData.value:
|
|
||||||
return None
|
|
||||||
windowId = int(propertyData.value[0])
|
|
||||||
return windowId if windowId > 0 else None
|
|
||||||
except Exception as error:
|
|
||||||
msg = f"INPUT EVENT MANAGER: Could not read EWMH active window: {error}"
|
|
||||||
debug.print_message(debug.LEVEL_INFO, msg, True)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _close_x11_display(self) -> None:
|
def _close_x11_display(self) -> None:
|
||||||
"""Closes the optional X11 display used for EWMH focus lookup."""
|
"""Closes the optional X11 display used for EWMH focus lookup."""
|
||||||
@@ -618,6 +649,7 @@ class InputEventManager:
|
|||||||
self._x11Root = None
|
self._x11Root = None
|
||||||
self._netActiveWindowAtom = None
|
self._netActiveWindowAtom = None
|
||||||
self._x11AnyPropertyType = None
|
self._x11AnyPropertyType = None
|
||||||
|
self._lastEwmhReadWindowId = None
|
||||||
self._lastEwmhActiveWindowId = None
|
self._lastEwmhActiveWindowId = None
|
||||||
self._lastEwmhXtermMatch = None
|
self._lastEwmhXtermMatch = None
|
||||||
if x11Display is None:
|
if x11Display is None:
|
||||||
@@ -762,20 +794,46 @@ class InputEventManager:
|
|||||||
self._poll_xterm_grab_recovery,
|
self._poll_xterm_grab_recovery,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _xterm_recovery_source_is_present(self) -> bool:
|
||||||
|
"""Returns whether GLib still owns the recorded recovery source."""
|
||||||
|
|
||||||
|
if not self._xtermRecoverySourceId:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
context = GLib.MainContext.default()
|
||||||
|
return context.find_source_by_id(self._xtermRecoverySourceId) is not None
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
def _poll_xterm_grab_recovery(self) -> bool:
|
def _poll_xterm_grab_recovery(self) -> bool:
|
||||||
"""Updates XTerm handoff state from the authoritative EWMH active window."""
|
"""Updates XTerm handoff state from the authoritative EWMH active window."""
|
||||||
|
|
||||||
|
self._xtermRecoveryPollCount += 1
|
||||||
if self._device is None:
|
if self._device is None:
|
||||||
self._xtermRecoverySourceId = 0
|
self._xtermRecoverySourceId = 0
|
||||||
return False
|
return False
|
||||||
|
|
||||||
match = self._active_x11_window_xterm_match()
|
try:
|
||||||
if match is True:
|
match = self._active_x11_window_xterm_match()
|
||||||
self._suspend_key_grabs_for_xterm()
|
if match is True:
|
||||||
elif match is False:
|
self._suspend_key_grabs_for_xterm()
|
||||||
self._restore_key_grabs_after_xterm()
|
elif match is False:
|
||||||
|
self._restore_key_grabs_after_xterm()
|
||||||
|
except Exception as error:
|
||||||
|
msg = f"INPUT EVENT MANAGER: XTerm recovery poll failed: {error}"
|
||||||
|
debug.print_message(debug.LEVEL_INFO, msg, True)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def _record_xterm_handoff_action(self, action: str) -> None:
|
||||||
|
"""Retains a short history of XTerm focus and grab-state transitions."""
|
||||||
|
|
||||||
|
if self._xtermHandoffHistory and self._xtermHandoffHistory[-1] == action:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._xtermHandoffHistory.append(action)
|
||||||
|
self._xtermHandoffHistory = self._xtermHandoffHistory[-12:]
|
||||||
|
|
||||||
def _on_active_x11_window_changed(self, screen: Any, _previousWindow: Any) -> None:
|
def _on_active_x11_window_changed(self, screen: Any, _previousWindow: Any) -> None:
|
||||||
"""Updates grab suspension as soon as X11 focus changes."""
|
"""Updates grab suspension as soon as X11 focus changes."""
|
||||||
|
|
||||||
@@ -793,7 +851,32 @@ class InputEventManager:
|
|||||||
def _active_x11_window_xterm_match(self) -> Optional[bool]:
|
def _active_x11_window_xterm_match(self) -> Optional[bool]:
|
||||||
"""Returns whether the active X11 window is XTerm, or None when unknown."""
|
"""Returns whether the active X11 window is XTerm, or None when unknown."""
|
||||||
|
|
||||||
windowId = self._get_ewmh_active_x11_window_id()
|
snapshot = cthulhu_state.compositorSnapshot
|
||||||
|
usingI3Context = snapshot is not None and snapshot.backend_name == "i3-ipc"
|
||||||
|
if usingI3Context and self._lastEwmhXtermMatch is True:
|
||||||
|
adapter = self._compositorStateAdapter
|
||||||
|
refreshContext = getattr(adapter, "refresh_workspace_context", None)
|
||||||
|
if callable(refreshContext):
|
||||||
|
snapshot = refreshContext("xterm-handoff-check")
|
||||||
|
usingI3Context = snapshot is not None and snapshot.backend_name == "i3-ipc"
|
||||||
|
if usingI3Context and snapshot.focused_workspace_empty is True:
|
||||||
|
self._record_xterm_handoff_action("i3:confirmed-empty-workspace")
|
||||||
|
self._lastEwmhReadWindowId = None
|
||||||
|
self._lastEwmhActiveWindowId = None
|
||||||
|
self._lastEwmhXtermMatch = False
|
||||||
|
return False
|
||||||
|
|
||||||
|
if usingI3Context and snapshot.focused_window_id is not None:
|
||||||
|
windowId = snapshot.focused_window_id
|
||||||
|
else:
|
||||||
|
windowId = self._get_ewmh_active_x11_window_id()
|
||||||
|
|
||||||
|
previousWindowId = self._lastEwmhReadWindowId
|
||||||
|
if windowId != previousWindowId:
|
||||||
|
previousText = hex(previousWindowId) if previousWindowId is not None else "unknown"
|
||||||
|
currentText = hex(windowId) if windowId is not None else "unknown"
|
||||||
|
self._record_xterm_handoff_action(f"ewmh:{previousText}->{currentText}")
|
||||||
|
self._lastEwmhReadWindowId = windowId
|
||||||
if (
|
if (
|
||||||
windowId is not None
|
windowId is not None
|
||||||
and windowId == self._lastEwmhActiveWindowId
|
and windowId == self._lastEwmhActiveWindowId
|
||||||
@@ -801,8 +884,10 @@ class InputEventManager:
|
|||||||
):
|
):
|
||||||
return self._lastEwmhXtermMatch
|
return self._lastEwmhXtermMatch
|
||||||
|
|
||||||
window = self._get_active_x11_window(ewmhWindowId=windowId)
|
if windowId is None:
|
||||||
match = self._x11_window_xterm_match(window)
|
return None
|
||||||
|
|
||||||
|
match = self._x11_window_id_xterm_match(windowId)
|
||||||
if windowId is not None and match is not None:
|
if windowId is not None and match is not None:
|
||||||
self._lastEwmhActiveWindowId = windowId
|
self._lastEwmhActiveWindowId = windowId
|
||||||
self._lastEwmhXtermMatch = match
|
self._lastEwmhXtermMatch = match
|
||||||
@@ -864,6 +949,80 @@ class InputEventManager:
|
|||||||
|
|
||||||
return self._identifier_is_xterm(executable)
|
return self._identifier_is_xterm(executable)
|
||||||
|
|
||||||
|
def _get_x11_window_metadata(
|
||||||
|
self,
|
||||||
|
windowId: int,
|
||||||
|
) -> Optional[Tuple[List[str], int]]:
|
||||||
|
"""Returns identifiers and pid directly from the exact X11 window id."""
|
||||||
|
|
||||||
|
if not self._ensure_x11_display():
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
window = self._x11Display.create_resource_object("window", windowId)
|
||||||
|
identifiers = [
|
||||||
|
value
|
||||||
|
for value in (window.get_wm_class() or ())
|
||||||
|
if isinstance(value, str) and value.strip()
|
||||||
|
]
|
||||||
|
|
||||||
|
name = window.get_wm_name()
|
||||||
|
if isinstance(name, str) and name.strip():
|
||||||
|
identifiers.append(name)
|
||||||
|
|
||||||
|
netWmNameAtom = self._x11Display.intern_atom("_NET_WM_NAME")
|
||||||
|
utf8StringAtom = self._x11Display.intern_atom("UTF8_STRING")
|
||||||
|
nameProperty = window.get_full_property(netWmNameAtom, utf8StringAtom)
|
||||||
|
if nameProperty is not None and nameProperty.value:
|
||||||
|
value = nameProperty.value
|
||||||
|
if isinstance(value, bytes):
|
||||||
|
value = value.decode(errors="ignore")
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
identifiers.append(value)
|
||||||
|
|
||||||
|
netWmPidAtom = self._x11Display.intern_atom("_NET_WM_PID")
|
||||||
|
pidProperty = window.get_full_property(
|
||||||
|
netWmPidAtom,
|
||||||
|
self._x11AnyPropertyType,
|
||||||
|
)
|
||||||
|
pid = -1
|
||||||
|
if pidProperty is not None and pidProperty.value:
|
||||||
|
pid = int(pidProperty.value[0])
|
||||||
|
except Exception as error:
|
||||||
|
msg = (
|
||||||
|
"INPUT EVENT MANAGER: Could not read metadata for X11 window "
|
||||||
|
f"{hex(windowId)}: {error}"
|
||||||
|
)
|
||||||
|
debug.print_message(debug.LEVEL_INFO, msg, True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
return identifiers, pid
|
||||||
|
|
||||||
|
def _x11_window_id_xterm_match(self, windowId: int) -> Optional[bool]:
|
||||||
|
"""Returns whether the exact EWMH X11 window id identifies XTerm."""
|
||||||
|
|
||||||
|
metadata = self._get_x11_window_metadata(windowId)
|
||||||
|
if metadata is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
identifiers, pid = metadata
|
||||||
|
if any(self._identifier_is_xterm(value) for value in identifiers):
|
||||||
|
return True
|
||||||
|
|
||||||
|
if pid > 0:
|
||||||
|
try:
|
||||||
|
with open(f"/proc/{pid}/cmdline", "rb") as cmdlineFile:
|
||||||
|
executable = cmdlineFile.read().split(b"\0", 1)[0].decode(errors="ignore")
|
||||||
|
if self._identifier_is_xterm(executable):
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if identifiers or pid > 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
def _active_x11_window_is_xterm(self) -> bool:
|
def _active_x11_window_is_xterm(self) -> bool:
|
||||||
"""Returns True when the active X11 window appears to be XTerm."""
|
"""Returns True when the active X11 window appears to be XTerm."""
|
||||||
|
|
||||||
@@ -1037,7 +1196,17 @@ class InputEventManager:
|
|||||||
"""Suspends active-script key grabs so XTerm/Fenrir can receive them."""
|
"""Suspends active-script key grabs so XTerm/Fenrir can receive them."""
|
||||||
|
|
||||||
script = script_manager.get_manager().get_active_script()
|
script = script_manager.get_manager().get_active_script()
|
||||||
if script is None or script is self._scriptWithSuspendedGrabsForXterm:
|
if script is None:
|
||||||
|
self._record_xterm_handoff_action("suspend:skipped-no-active-script")
|
||||||
|
return
|
||||||
|
|
||||||
|
if script is self._scriptWithSuspendedGrabsForXterm:
|
||||||
|
grabIds = getattr(script, "grab_ids", [])
|
||||||
|
scriptGrabCount = len(grabIds) if isinstance(grabIds, list) else -1
|
||||||
|
self._record_xterm_handoff_action(
|
||||||
|
f"suspend:already-marked:script-grabs={scriptGrabCount}:"
|
||||||
|
f"all-grabs={len(self._grabbed_bindings)}"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if self._scriptWithSuspendedGrabsForXterm is not None:
|
if self._scriptWithSuspendedGrabsForXterm is not None:
|
||||||
@@ -1054,6 +1223,9 @@ class InputEventManager:
|
|||||||
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
|
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
|
||||||
removeGrabs()
|
removeGrabs()
|
||||||
self._scriptWithSuspendedGrabsForXterm = script
|
self._scriptWithSuspendedGrabsForXterm = script
|
||||||
|
self._record_xterm_handoff_action(
|
||||||
|
f"suspend:success:all-grabs={len(self._grabbed_bindings)}"
|
||||||
|
)
|
||||||
self._ensure_xterm_recovery_timer()
|
self._ensure_xterm_recovery_timer()
|
||||||
|
|
||||||
def _restore_key_grabs_after_xterm(self) -> None:
|
def _restore_key_grabs_after_xterm(self) -> None:
|
||||||
@@ -1065,6 +1237,7 @@ class InputEventManager:
|
|||||||
|
|
||||||
activeScript = script_manager.get_manager().get_active_script()
|
activeScript = script_manager.get_manager().get_active_script()
|
||||||
if script is not activeScript:
|
if script is not activeScript:
|
||||||
|
self._record_xterm_handoff_action("restore:dropped-active-script-changed")
|
||||||
tokens = [
|
tokens = [
|
||||||
"INPUT EVENT MANAGER: Not restoring XTerm-suspended key grabs; active script changed:",
|
"INPUT EVENT MANAGER: Not restoring XTerm-suspended key grabs; active script changed:",
|
||||||
script,
|
script,
|
||||||
@@ -1086,6 +1259,7 @@ class InputEventManager:
|
|||||||
try:
|
try:
|
||||||
addGrabs()
|
addGrabs()
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
|
self._record_xterm_handoff_action(f"restore:failed:{type(error).__name__}")
|
||||||
msg = f"INPUT EVENT MANAGER: Could not restore XTerm-suspended key grabs: {error}"
|
msg = f"INPUT EVENT MANAGER: Could not restore XTerm-suspended key grabs: {error}"
|
||||||
debug.print_message(debug.LEVEL_INFO, msg, True)
|
debug.print_message(debug.LEVEL_INFO, msg, True)
|
||||||
removeGrabs = getattr(script, "removeKeyGrabs", None)
|
removeGrabs = getattr(script, "removeKeyGrabs", None)
|
||||||
@@ -1104,6 +1278,9 @@ class InputEventManager:
|
|||||||
return
|
return
|
||||||
|
|
||||||
self._scriptWithSuspendedGrabsForXterm = None
|
self._scriptWithSuspendedGrabsForXterm = None
|
||||||
|
self._record_xterm_handoff_action(
|
||||||
|
f"restore:success:all-grabs={len(self._grabbed_bindings)}"
|
||||||
|
)
|
||||||
|
|
||||||
# pylint: disable=too-many-arguments
|
# pylint: disable=too-many-arguments
|
||||||
# pylint: disable=too-many-positional-arguments
|
# pylint: disable=too-many-positional-arguments
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ cthulhu_python_sources = files([
|
|||||||
'cmdnames.py',
|
'cmdnames.py',
|
||||||
'colornames.py',
|
'colornames.py',
|
||||||
'compositor_state_adapter.py',
|
'compositor_state_adapter.py',
|
||||||
|
'compositor_state_i3.py',
|
||||||
'compositor_state_types.py',
|
'compositor_state_types.py',
|
||||||
'compositor_state_wayland.py',
|
'compositor_state_wayland.py',
|
||||||
'common_keyboardmap.py',
|
'common_keyboardmap.py',
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ class WindowTitleReader(Plugin):
|
|||||||
self,
|
self,
|
||||||
overwrite=True,
|
overwrite=True,
|
||||||
)
|
)
|
||||||
if xlibAvailable:
|
if xlibAvailable or self._get_i3_title_context() is not None:
|
||||||
self._start_tracking()
|
self._start_tracking()
|
||||||
self._activated = True
|
self._activated = True
|
||||||
debug.printMessage(debug.LEVEL_INFO, "WindowTitleReader: Activated", True)
|
debug.printMessage(debug.LEVEL_INFO, "WindowTitleReader: Activated", True)
|
||||||
@@ -136,7 +136,7 @@ class WindowTitleReader(Plugin):
|
|||||||
self._present_message("Window title reader off")
|
self._present_message("Window title reader off")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if not xlibAvailable:
|
if not xlibAvailable and self._get_i3_title_context() is None:
|
||||||
self._present_message("Window title reader unavailable")
|
self._present_message("Window title reader unavailable")
|
||||||
debug.printMessage(
|
debug.printMessage(
|
||||||
debug.LEVEL_INFO,
|
debug.LEVEL_INFO,
|
||||||
@@ -167,7 +167,7 @@ class WindowTitleReader(Plugin):
|
|||||||
self._present_message("Window title reader on")
|
self._present_message("Window title reader on")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if not xlibAvailable:
|
if not xlibAvailable and self._get_i3_title_context() is None:
|
||||||
if notify_user:
|
if notify_user:
|
||||||
self._present_message("Window title reader unavailable")
|
self._present_message("Window title reader unavailable")
|
||||||
debug.printMessage(
|
debug.printMessage(
|
||||||
@@ -218,9 +218,9 @@ class WindowTitleReader(Plugin):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._display = xDisplay.Display()
|
if self._get_i3_title_context() is None:
|
||||||
self._root = self._display.screen().root
|
if not self._ensure_x11_display():
|
||||||
self._init_atoms()
|
return False
|
||||||
self._pollSourceId = GLib.timeout_add(self._pollIntervalMs, self._poll_window_title)
|
self._pollSourceId = GLib.timeout_add(self._pollIntervalMs, self._poll_window_title)
|
||||||
self._poll_window_title()
|
self._poll_window_title()
|
||||||
return True
|
return True
|
||||||
@@ -273,17 +273,24 @@ class WindowTitleReader(Plugin):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
activeWindow = self._get_active_window()
|
i3Context = self._get_i3_title_context()
|
||||||
if not activeWindow:
|
if i3Context is not None:
|
||||||
|
activeWindowId, windowTitle = i3Context
|
||||||
|
else:
|
||||||
|
if not self._ensure_x11_display():
|
||||||
|
self._lastTitle = None
|
||||||
|
return True
|
||||||
|
activeWindow = self._get_active_window()
|
||||||
|
if not activeWindow:
|
||||||
|
self._lastTitle = None
|
||||||
|
return True
|
||||||
|
activeWindowId = activeWindow.id
|
||||||
|
windowTitle = self._get_current_title(activeWindow)
|
||||||
|
|
||||||
|
if activeWindowId is None or not windowTitle:
|
||||||
self._lastTitle = None
|
self._lastTitle = None
|
||||||
return True
|
return True
|
||||||
|
|
||||||
windowTitle = self._get_current_title(activeWindow)
|
|
||||||
if not windowTitle:
|
|
||||||
self._lastTitle = None
|
|
||||||
return True
|
|
||||||
|
|
||||||
activeWindowId = activeWindow.id
|
|
||||||
if self._lastActiveWindowId is None:
|
if self._lastActiveWindowId is None:
|
||||||
self._lastActiveWindowId = activeWindowId
|
self._lastActiveWindowId = activeWindowId
|
||||||
elif activeWindowId != self._lastActiveWindowId:
|
elif activeWindowId != self._lastActiveWindowId:
|
||||||
@@ -321,12 +328,25 @@ class WindowTitleReader(Plugin):
|
|||||||
def get_fallback_title(self, atspiTitle=None):
|
def get_fallback_title(self, atspiTitle=None):
|
||||||
"""Returns the X11 title when AT-SPI has not exposed an equivalent title."""
|
"""Returns the X11 title when AT-SPI has not exposed an equivalent title."""
|
||||||
|
|
||||||
|
i3Context = self._get_i3_title_context()
|
||||||
|
if i3Context is not None:
|
||||||
|
_windowId, titleText = i3Context
|
||||||
|
if not titleText:
|
||||||
|
return ""
|
||||||
|
if atspiTitle is None:
|
||||||
|
atspiTitle = self._get_atspi_title()
|
||||||
|
if self._titles_match(atspiTitle, titleText):
|
||||||
|
return ""
|
||||||
|
return titleText
|
||||||
|
|
||||||
if not xlibAvailable and self._display is None:
|
if not xlibAvailable and self._display is None:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
startedTracking = self._pollSourceId is not None
|
startedTracking = self._pollSourceId is not None
|
||||||
if not startedTracking and not self._start_tracking():
|
if not startedTracking and not self._start_tracking():
|
||||||
return ""
|
return ""
|
||||||
|
if not self._ensure_x11_display():
|
||||||
|
return ""
|
||||||
|
|
||||||
activeWindow = self._get_active_window()
|
activeWindow = self._get_active_window()
|
||||||
titleText = self._get_current_title(activeWindow) if activeWindow else ""
|
titleText = self._get_current_title(activeWindow) if activeWindow else ""
|
||||||
@@ -341,6 +361,65 @@ class WindowTitleReader(Plugin):
|
|||||||
|
|
||||||
return titleText
|
return titleText
|
||||||
|
|
||||||
|
def _get_i3_title_context(self):
|
||||||
|
"""Returns authoritative i3 window id/title, or None when i3 is not active."""
|
||||||
|
|
||||||
|
if not self.app:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
adapter = self.app.getCompositorStateAdapter()
|
||||||
|
snapshot = adapter.get_snapshot() if adapter else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if snapshot is None or getattr(snapshot, "backend_name", "") != "i3-ipc":
|
||||||
|
return None
|
||||||
|
|
||||||
|
if getattr(snapshot, "focused_workspace_empty", None) is True:
|
||||||
|
return (None, "")
|
||||||
|
|
||||||
|
windowId = getattr(snapshot, "focused_window_id", None)
|
||||||
|
titleText = str(getattr(snapshot, "focused_window_title", "") or "").strip()
|
||||||
|
if windowId is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not titleText or self._is_wine_desktop_title(titleText):
|
||||||
|
if not self._ensure_x11_display():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
activeWindow = self._display.create_resource_object("window", windowId)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
titleText = self._get_current_title(activeWindow)
|
||||||
|
if not titleText:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return (windowId, titleText)
|
||||||
|
|
||||||
|
def _ensure_x11_display(self):
|
||||||
|
"""Lazily opens the generic X11 title source when i3 IPC is unavailable."""
|
||||||
|
|
||||||
|
if self._display is not None:
|
||||||
|
return True
|
||||||
|
if not xlibAvailable:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._display = xDisplay.Display()
|
||||||
|
self._root = self._display.screen().root
|
||||||
|
self._init_atoms()
|
||||||
|
except Exception as error:
|
||||||
|
debug.printMessage(
|
||||||
|
debug.LEVEL_INFO,
|
||||||
|
f"WindowTitleReader: Failed to open X11 fallback: {error}",
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
self._cleanup_display()
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
def _get_atspi_title(self):
|
def _get_atspi_title(self):
|
||||||
if not self.app:
|
if not self.app:
|
||||||
return ""
|
return ""
|
||||||
@@ -386,9 +465,11 @@ class WindowTitleReader(Plugin):
|
|||||||
|
|
||||||
def _get_wine_desktop_title(self, desktopWindow):
|
def _get_wine_desktop_title(self, desktopWindow):
|
||||||
focusWindow = self._get_focus_window()
|
focusWindow = self._get_focus_window()
|
||||||
if focusWindow and focusWindow.id != desktopWindow.id:
|
if focusWindow \
|
||||||
|
and focusWindow.id != desktopWindow.id \
|
||||||
|
and self._window_is_descendant_of(focusWindow, desktopWindow):
|
||||||
focusTitle = self._get_window_title(focusWindow)
|
focusTitle = self._get_window_title(focusWindow)
|
||||||
if focusTitle and not self._is_wine_desktop_title(focusTitle):
|
if self._is_usable_wine_child_title(focusWindow, focusTitle):
|
||||||
return focusTitle
|
return focusTitle
|
||||||
|
|
||||||
return self._find_child_title(desktopWindow)
|
return self._find_child_title(desktopWindow)
|
||||||
@@ -404,7 +485,7 @@ class WindowTitleReader(Plugin):
|
|||||||
window = childQueue.pop(0)
|
window = childQueue.pop(0)
|
||||||
checkedCount += 1
|
checkedCount += 1
|
||||||
titleText = self._get_window_title(window)
|
titleText = self._get_window_title(window)
|
||||||
if titleText and not self._is_wine_desktop_title(titleText):
|
if self._is_usable_wine_child_title(window, titleText):
|
||||||
return titleText
|
return titleText
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -414,6 +495,47 @@ class WindowTitleReader(Plugin):
|
|||||||
|
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
def _window_is_descendant_of(self, window, ancestor):
|
||||||
|
if not window or not ancestor:
|
||||||
|
return False
|
||||||
|
|
||||||
|
current = window
|
||||||
|
for _hop in range(64):
|
||||||
|
if current.id == ancestor.id:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
parent = current.query_tree().parent
|
||||||
|
except xError.XError:
|
||||||
|
return False
|
||||||
|
if not parent or parent.id == current.id:
|
||||||
|
return False
|
||||||
|
current = parent
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _is_usable_wine_child_title(self, window, titleText):
|
||||||
|
if not titleText or self._is_wine_desktop_title(titleText):
|
||||||
|
return False
|
||||||
|
|
||||||
|
normalizedTitle = " ".join(titleText.casefold().split())
|
||||||
|
if normalizedTitle in ("default ime", "program manager"):
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
windowClass = window.get_wm_class() or ()
|
||||||
|
except xError.XError:
|
||||||
|
windowClass = ()
|
||||||
|
normalizedClass = {
|
||||||
|
str(value or "").strip().casefold()
|
||||||
|
for value in windowClass
|
||||||
|
}
|
||||||
|
if "cthulhu-wine-access.exe" in normalizedClass:
|
||||||
|
return False
|
||||||
|
if normalizedTitle == "nvda" and "nvda.exe" in normalizedClass:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
def _is_wine_desktop_title(self, titleText):
|
def _is_wine_desktop_title(self, titleText):
|
||||||
return self._wineDesktopLabel.lower() in titleText.lower()
|
return self._wineDesktopLabel.lower() in titleText.lower()
|
||||||
|
|
||||||
|
|||||||
@@ -1152,7 +1152,13 @@ class Script(default.Script):
|
|||||||
def sayLine(self, obj):
|
def sayLine(self, obj):
|
||||||
"""Speaks the line at the current caret position."""
|
"""Speaks the line at the current caret position."""
|
||||||
|
|
||||||
isEditable = self.utilities.isContentEditableWithEmbeddedObjects(obj)
|
isNativeMultilineEditable = (
|
||||||
|
self.utilities.inDocumentContent(obj)
|
||||||
|
and AXUtilities.is_editable(obj)
|
||||||
|
and AXUtilities.is_multi_line(obj)
|
||||||
|
)
|
||||||
|
isEditable = self.utilities.isContentEditableWithEmbeddedObjects(obj) \
|
||||||
|
or isNativeMultilineEditable
|
||||||
if not (self._lastCommandWasCaretNav or self._lastCommandWasStructNav) and not isEditable:
|
if not (self._lastCommandWasCaretNav or self._lastCommandWasStructNav) and not isEditable:
|
||||||
super().sayLine(obj)
|
super().sayLine(obj)
|
||||||
return
|
return
|
||||||
@@ -1164,7 +1170,10 @@ class Script(default.Script):
|
|||||||
|
|
||||||
obj, offset = self.utilities.getCaretContext(documentFrame=document)
|
obj, offset = self.utilities.getCaretContext(documentFrame=document)
|
||||||
contents = self.utilities.getLineContentsAtOffset(obj, offset, useCache=True)
|
contents = self.utilities.getLineContentsAtOffset(obj, offset, useCache=True)
|
||||||
self.speakContents(contents, priorObj=priorObj)
|
if isNativeMultilineEditable:
|
||||||
|
self.speakContents(contents, priorObj=priorObj, alreadyFocused=True)
|
||||||
|
else:
|
||||||
|
self.speakContents(contents, priorObj=priorObj)
|
||||||
self.pointOfReference["lastTextUnitSpoken"] = "line"
|
self.pointOfReference["lastTextUnitSpoken"] = "line"
|
||||||
|
|
||||||
def presentObject(self, obj, **args):
|
def presentObject(self, obj, **args):
|
||||||
@@ -2651,20 +2660,6 @@ class Script(default.Script):
|
|||||||
self.utilities.setCaretContext(event.source, 0)
|
self.utilities.setCaretContext(event.source, 0)
|
||||||
obj, offset = event.source, 0
|
obj, offset = event.source, 0
|
||||||
|
|
||||||
if self._lastCommandWasCaretNav \
|
|
||||||
and obj is not event.source \
|
|
||||||
and AXUtilities.is_focusable(event.source) \
|
|
||||||
and AXUtilities.is_focused(event.source) \
|
|
||||||
and self.utilities.inDocumentContent(event.source) \
|
|
||||||
and self.utilities.isLink(event.source) \
|
|
||||||
and obj \
|
|
||||||
and AXUtilities.is_section(obj):
|
|
||||||
msg = "WEB: Event handled: Replacing stale section context with focused link"
|
|
||||||
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
|
||||||
cthulhu.setLocusOfFocus(event, event.source, False)
|
|
||||||
self.utilities.setCaretContext(event.source, 0)
|
|
||||||
return True
|
|
||||||
|
|
||||||
if self._lastCommandWasCaretNav:
|
if self._lastCommandWasCaretNav:
|
||||||
msg = "WEB: Event ignored: Last command was caret nav"
|
msg = "WEB: Event ignored: Last command was caret nav"
|
||||||
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
||||||
|
|||||||
@@ -1822,6 +1822,17 @@ class Utilities(script_utilities.Utilities):
|
|||||||
string = AXText.get_substring(obj, offset, firstEnd)
|
string = AXText.get_substring(obj, offset, firstEnd)
|
||||||
return [[firstObj, offset, firstEnd, string]] + contents[1:]
|
return [[firstObj, offset, firstEnd, string]] + contents[1:]
|
||||||
|
|
||||||
|
def _getEditableLineBoundary(self, obj):
|
||||||
|
if self.isContentEditableWithEmbeddedObjects(obj):
|
||||||
|
return obj
|
||||||
|
|
||||||
|
if self.inDocumentContent(obj) \
|
||||||
|
and AXUtilities.is_editable(obj) \
|
||||||
|
and AXUtilities.is_multi_line(obj):
|
||||||
|
return obj
|
||||||
|
|
||||||
|
return AXObject.find_ancestor(obj, self.isContentEditableWithEmbeddedObjects)
|
||||||
|
|
||||||
def _getLineContentsAtOffset(self, obj, offset, layoutMode=None, useCache=True):
|
def _getLineContentsAtOffset(self, obj, offset, layoutMode=None, useCache=True):
|
||||||
startTime = time.time()
|
startTime = time.time()
|
||||||
if not obj:
|
if not obj:
|
||||||
@@ -1930,15 +1941,10 @@ class Utilities(script_utilities.Utilities):
|
|||||||
prevObj, pOffset = self.findPreviousCaretInOrder(firstObj, firstStart)
|
prevObj, pOffset = self.findPreviousCaretInOrder(firstObj, firstStart)
|
||||||
nextObj, nOffset = self.findNextCaretInOrder(lastObj, lastEnd - 1)
|
nextObj, nOffset = self.findNextCaretInOrder(lastObj, lastEnd - 1)
|
||||||
|
|
||||||
# If we're inside a content editable, don't expand line contents beyond
|
# If we're inside an editable, don't expand line contents beyond its
|
||||||
# its boundaries (e.g. don't include a "More options" button adjacent to
|
# boundaries (e.g. don't include an adjacent button just because it's
|
||||||
# a message entry just because it's on the same visual line).
|
# on the same visual line).
|
||||||
contentEditableBoundary = None
|
editableBoundary = self._getEditableLineBoundary(obj)
|
||||||
if self.isContentEditableWithEmbeddedObjects(obj):
|
|
||||||
contentEditableBoundary = obj
|
|
||||||
else:
|
|
||||||
contentEditableBoundary = AXObject.find_ancestor(
|
|
||||||
obj, self.isContentEditableWithEmbeddedObjects)
|
|
||||||
|
|
||||||
# Check for things on the same line to the left of this object.
|
# Check for things on the same line to the left of this object.
|
||||||
prevStartTime = time.time()
|
prevStartTime = time.time()
|
||||||
@@ -1954,8 +1960,8 @@ class Utilities(script_utilities.Utilities):
|
|||||||
if objRow != AXObject.find_ancestor(prevObj, AXUtilities.is_table_row):
|
if objRow != AXObject.find_ancestor(prevObj, AXUtilities.is_table_row):
|
||||||
break
|
break
|
||||||
|
|
||||||
if contentEditableBoundary and prevObj != contentEditableBoundary \
|
if editableBoundary and prevObj != editableBoundary \
|
||||||
and not AXObject.find_ancestor(prevObj, lambda x: x == contentEditableBoundary):
|
and not AXObject.find_ancestor(prevObj, lambda x: x == editableBoundary):
|
||||||
break
|
break
|
||||||
|
|
||||||
onLeft = self._getContentsForObj(prevObj, pOffset, boundary)
|
onLeft = self._getContentsForObj(prevObj, pOffset, boundary)
|
||||||
@@ -1988,8 +1994,8 @@ class Utilities(script_utilities.Utilities):
|
|||||||
if objRow != AXObject.find_ancestor(nextObj, AXUtilities.is_table_row):
|
if objRow != AXObject.find_ancestor(nextObj, AXUtilities.is_table_row):
|
||||||
break
|
break
|
||||||
|
|
||||||
if contentEditableBoundary and nextObj != contentEditableBoundary \
|
if editableBoundary and nextObj != editableBoundary \
|
||||||
and not AXObject.find_ancestor(nextObj, lambda x: x == contentEditableBoundary):
|
and not AXObject.find_ancestor(nextObj, lambda x: x == editableBoundary):
|
||||||
break
|
break
|
||||||
|
|
||||||
onRight = self._getContentsForObj(nextObj, nOffset, boundary)
|
onRight = self._getContentsForObj(nextObj, nOffset, boundary)
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import re
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class ArchPkgbuildOptionalWineTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls) -> None:
|
||||||
|
repository = Path(__file__).resolve().parents[1]
|
||||||
|
cls.repository = repository
|
||||||
|
versionContents = (
|
||||||
|
repository / "src" / "cthulhu" / "cthulhuVersion.py"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
versionMatch = re.search(r'(?m)^version = "([^"]+)"$', versionContents)
|
||||||
|
if versionMatch is None:
|
||||||
|
raise AssertionError("Unable to read the current Cthulhu version")
|
||||||
|
cls.releaseVersion = versionMatch.group(1)
|
||||||
|
cls.pkgbuilds = [
|
||||||
|
repository / "distro-packages" / "Arch-Linux" / name / "PKGBUILD"
|
||||||
|
for name in ("cthulhu", "cthulhu-git")
|
||||||
|
]
|
||||||
|
cls.helperPkgbuilds = [
|
||||||
|
repository / "distro-packages" / "Arch-Linux" / name / "PKGBUILD"
|
||||||
|
for name in ("cthulhu-wine-access", "cthulhu-wine-access-git")
|
||||||
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _array_contents(contents: str, arrayName: str) -> str:
|
||||||
|
return contents.split(f"{arrayName}=(", 1)[1].split(")", 1)[0]
|
||||||
|
|
||||||
|
def test_main_packages_disable_wine_helper_build(self) -> None:
|
||||||
|
for pkgbuild in self.pkgbuilds:
|
||||||
|
with self.subTest(pkgbuild=pkgbuild):
|
||||||
|
contents = pkgbuild.read_text(encoding="utf-8")
|
||||||
|
self.assertIn("-Dwine-access=disabled", contents)
|
||||||
|
|
||||||
|
def test_main_packages_do_not_depend_on_wine(self) -> None:
|
||||||
|
for pkgbuild in self.pkgbuilds:
|
||||||
|
with self.subTest(pkgbuild=pkgbuild):
|
||||||
|
contents = pkgbuild.read_text(encoding="utf-8")
|
||||||
|
dependencySection = contents.split("source=(", 1)[0]
|
||||||
|
dependencyLines = {
|
||||||
|
line.strip().strip("'")
|
||||||
|
for line in dependencySection.splitlines()
|
||||||
|
if line.strip() and not line.lstrip().startswith("#")
|
||||||
|
}
|
||||||
|
self.assertNotIn("wine", dependencyLines)
|
||||||
|
|
||||||
|
def test_main_packages_advertise_matching_optional_helper(self) -> None:
|
||||||
|
stableContents = self.pkgbuilds[0].read_text(encoding="utf-8")
|
||||||
|
self.assertIn(
|
||||||
|
"'cthulhu-wine-access: Wine, Proton, NVDA Controller, and Tolk integration'",
|
||||||
|
self._array_contents(stableContents, "optdepends_x86_64"),
|
||||||
|
)
|
||||||
|
self.assertNotIn(
|
||||||
|
"cthulhu-wine-access",
|
||||||
|
self._array_contents(stableContents, "optdepends"),
|
||||||
|
)
|
||||||
|
|
||||||
|
gitContents = self.pkgbuilds[1].read_text(encoding="utf-8")
|
||||||
|
self.assertIn(
|
||||||
|
"'cthulhu-wine-access-git: Wine, Proton, NVDA Controller, and Tolk integration'",
|
||||||
|
self._array_contents(gitContents, "optdepends_x86_64"),
|
||||||
|
)
|
||||||
|
self.assertNotIn(
|
||||||
|
"cthulhu-wine-access-git",
|
||||||
|
self._array_contents(gitContents, "optdepends"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_stable_packages_use_current_release_version(self) -> None:
|
||||||
|
for pkgbuild in (self.pkgbuilds[0], self.helperPkgbuilds[0]):
|
||||||
|
with self.subTest(pkgbuild=pkgbuild):
|
||||||
|
contents = pkgbuild.read_text(encoding="utf-8")
|
||||||
|
self.assertRegex(
|
||||||
|
contents,
|
||||||
|
rf"(?m)^pkgver={re.escape(self.releaseVersion)}$",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_release_metadata_uses_current_release_version(self) -> None:
|
||||||
|
mesonContents = (self.repository / "meson.build").read_text(encoding="utf-8")
|
||||||
|
self.assertRegex(
|
||||||
|
mesonContents,
|
||||||
|
rf"(?m)^ version: '{re.escape(self.releaseVersion)}(?:-[^']+)?',$",
|
||||||
|
)
|
||||||
|
|
||||||
|
expectedSlackwareVersion = f"VERSION=${{VERSION:-{self.releaseVersion}}}"
|
||||||
|
for relativePath in (
|
||||||
|
("distro-packages", "Slackware", "cthulhu.SlackBuild"),
|
||||||
|
("distro-packages", "Slint", "cthulhu.SlackBuild"),
|
||||||
|
):
|
||||||
|
with self.subTest(relativePath=relativePath):
|
||||||
|
contents = self.repository.joinpath(*relativePath).read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
self.assertIn(expectedSlackwareVersion, contents)
|
||||||
|
|
||||||
|
slintInfo = (
|
||||||
|
self.repository / "distro-packages" / "Slint" / "cthulhu-info"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
self.assertRegex(
|
||||||
|
slintInfo,
|
||||||
|
rf'(?m)^VERSION="{re.escape(self.releaseVersion)}"$',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_stable_helper_has_release_contract(self) -> None:
|
||||||
|
contents = self.helperPkgbuilds[0].read_text(encoding="utf-8")
|
||||||
|
self.assertRegex(contents, r"(?m)^arch=\(x86_64\)$")
|
||||||
|
self.assertRegex(contents, r'(?m)^ "cthulhu=\$\{pkgver\}"$')
|
||||||
|
|
||||||
|
def test_optional_helper_packages_own_the_wine_dependency(self) -> None:
|
||||||
|
for pkgbuild in self.helperPkgbuilds:
|
||||||
|
with self.subTest(pkgbuild=pkgbuild):
|
||||||
|
contents = pkgbuild.read_text(encoding="utf-8")
|
||||||
|
self.assertIn("-Dwine-access=enabled", contents)
|
||||||
|
self.assertRegex(contents, r"(?m)^ wine$")
|
||||||
|
|
||||||
|
def test_optional_helper_packages_disable_lto(self) -> None:
|
||||||
|
for pkgbuild in self.helperPkgbuilds:
|
||||||
|
with self.subTest(pkgbuild=pkgbuild):
|
||||||
|
contents = pkgbuild.read_text(encoding="utf-8")
|
||||||
|
self.assertRegex(contents, r"(?m)^options=\(!lto\)$")
|
||||||
|
|
||||||
|
def test_git_sources_declare_git_as_a_build_dependency(self) -> None:
|
||||||
|
for pkgbuild in self.pkgbuilds + self.helperPkgbuilds:
|
||||||
|
with self.subTest(pkgbuild=pkgbuild):
|
||||||
|
contents = pkgbuild.read_text(encoding="utf-8")
|
||||||
|
self.assertIn("git+", contents)
|
||||||
|
makeDependencies = contents.split("makedepends=(", 1)[1].split(
|
||||||
|
")", 1
|
||||||
|
)[0]
|
||||||
|
self.assertRegex(makeDependencies, r"(?m)^ git$")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -11,6 +11,7 @@ import cthulhu as cthulhu_package
|
|||||||
|
|
||||||
from cthulhu import cthulhu_state
|
from cthulhu import cthulhu_state
|
||||||
from cthulhu import compositor_state_adapter
|
from cthulhu import compositor_state_adapter
|
||||||
|
from cthulhu import compositor_state_i3
|
||||||
from cthulhu import compositor_state_types
|
from cthulhu import compositor_state_types
|
||||||
from cthulhu import compositor_state_wayland
|
from cthulhu import compositor_state_wayland
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ class FakeWorkspaceBackend:
|
|||||||
self.name = name
|
self.name = name
|
||||||
self.activate_calls = []
|
self.activate_calls = []
|
||||||
self.deactivate_calls = []
|
self.deactivate_calls = []
|
||||||
|
self.refresh_calls = []
|
||||||
|
|
||||||
def is_available(self, session_type: str | None = None) -> bool:
|
def is_available(self, session_type: str | None = None) -> bool:
|
||||||
return self.available
|
return self.available
|
||||||
@@ -51,6 +53,9 @@ class FakeWorkspaceBackend:
|
|||||||
def deactivate(self, emit_signal=None) -> None:
|
def deactivate(self, emit_signal=None) -> None:
|
||||||
self.deactivate_calls.append(emit_signal)
|
self.deactivate_calls.append(emit_signal)
|
||||||
|
|
||||||
|
def refresh_context(self, reason: str) -> None:
|
||||||
|
self.refresh_calls.append(reason)
|
||||||
|
|
||||||
|
|
||||||
class CompositorStateAdapterRegressionTests(unittest.TestCase):
|
class CompositorStateAdapterRegressionTests(unittest.TestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
@@ -74,7 +79,7 @@ class CompositorStateAdapterRegressionTests(unittest.TestCase):
|
|||||||
self.assertTrue(callable(selectedBackend.activate_calls[0]))
|
self.assertTrue(callable(selectedBackend.activate_calls[0]))
|
||||||
self.assertEqual(adapter.get_snapshot().backend_name, "selected")
|
self.assertEqual(adapter.get_snapshot().backend_name, "selected")
|
||||||
|
|
||||||
def test_default_workspace_backends_include_wayland_then_null_backend(self) -> None:
|
def test_default_workspace_backends_include_i3_wayland_then_null_backend(self) -> None:
|
||||||
adapter = compositor_state_adapter.CompositorStateAdapter()
|
adapter = compositor_state_adapter.CompositorStateAdapter()
|
||||||
|
|
||||||
backend_types = [type(backend) for backend in adapter._workspaceBackends]
|
backend_types = [type(backend) for backend in adapter._workspaceBackends]
|
||||||
@@ -82,11 +87,78 @@ class CompositorStateAdapterRegressionTests(unittest.TestCase):
|
|||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
backend_types,
|
backend_types,
|
||||||
[
|
[
|
||||||
|
compositor_state_i3.I3WorkspaceBackend,
|
||||||
compositor_state_wayland.WaylandSharedProtocolsBackend,
|
compositor_state_wayland.WaylandSharedProtocolsBackend,
|
||||||
compositor_state_wayland.NullWorkspaceBackend,
|
compositor_state_wayland.NullWorkspaceBackend,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_workspace_payload_records_i3_foreground_context(self) -> None:
|
||||||
|
backend = FakeWorkspaceBackend(True, "i3-ipc")
|
||||||
|
adapter = compositor_state_adapter.CompositorStateAdapter(workspace_backends=[backend])
|
||||||
|
|
||||||
|
adapter.activate()
|
||||||
|
backend.activate_calls[-1](
|
||||||
|
compositor_state_types.WORKSPACE_STATE_CHANGED,
|
||||||
|
{"8"},
|
||||||
|
"i3-window-focus",
|
||||||
|
{
|
||||||
|
"focused_window_id": 0x200000C,
|
||||||
|
"focused_window_title": "9 devel",
|
||||||
|
"focused_workspace_empty": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
snapshot = adapter.get_snapshot()
|
||||||
|
self.assertEqual(snapshot.active_workspace_ids, frozenset({"8"}))
|
||||||
|
self.assertEqual(snapshot.focused_window_id, 0x200000C)
|
||||||
|
self.assertEqual(snapshot.focused_window_title, "9 devel")
|
||||||
|
self.assertFalse(snapshot.focused_workspace_empty)
|
||||||
|
|
||||||
|
def test_refresh_workspace_context_queries_selected_backend(self) -> None:
|
||||||
|
backend = FakeWorkspaceBackend(True, "i3-ipc")
|
||||||
|
adapter = compositor_state_adapter.CompositorStateAdapter(workspace_backends=[backend])
|
||||||
|
adapter.activate()
|
||||||
|
|
||||||
|
snapshot = adapter.refresh_workspace_context("xterm-handoff-check")
|
||||||
|
|
||||||
|
self.assertEqual(backend.refresh_calls, ["xterm-handoff-check"])
|
||||||
|
self.assertIs(snapshot, adapter.get_snapshot())
|
||||||
|
|
||||||
|
def test_confirmed_empty_workspace_clears_stale_accessible_context(self) -> None:
|
||||||
|
backend = FakeWorkspaceBackend(True, "i3-ipc")
|
||||||
|
adapter = compositor_state_adapter.CompositorStateAdapter(workspace_backends=[backend])
|
||||||
|
adapter.activate()
|
||||||
|
adapter._snapshot = compositor_state_types.DesktopContextSnapshot(
|
||||||
|
session_type="x11",
|
||||||
|
backend_name="i3-ipc",
|
||||||
|
active_window_token="42:Browser",
|
||||||
|
locus_of_focus_token="42:Web content",
|
||||||
|
active_window_pid=42,
|
||||||
|
locus_of_focus_pid=42,
|
||||||
|
active_window_name="Browser",
|
||||||
|
locus_of_focus_name="Web content",
|
||||||
|
)
|
||||||
|
|
||||||
|
backend.activate_calls[-1](
|
||||||
|
compositor_state_types.WORKSPACE_STATE_CHANGED,
|
||||||
|
{"9"},
|
||||||
|
"i3-workspace-focus",
|
||||||
|
{
|
||||||
|
"focused_window_id": None,
|
||||||
|
"focused_window_title": "",
|
||||||
|
"focused_workspace_empty": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
snapshot = adapter.get_snapshot()
|
||||||
|
self.assertEqual(snapshot.active_window_token, "")
|
||||||
|
self.assertEqual(snapshot.locus_of_focus_token, "")
|
||||||
|
self.assertEqual(snapshot.active_window_pid, -1)
|
||||||
|
self.assertEqual(snapshot.locus_of_focus_pid, -1)
|
||||||
|
self.assertEqual(snapshot.active_window_name, "")
|
||||||
|
self.assertEqual(snapshot.locus_of_focus_name, "")
|
||||||
|
|
||||||
def test_wayland_backend_is_unavailable_without_wayland_session(self) -> None:
|
def test_wayland_backend_is_unavailable_without_wayland_session(self) -> None:
|
||||||
backend = compositor_state_wayland.WaylandSharedProtocolsBackend()
|
backend = compositor_state_wayland.WaylandSharedProtocolsBackend()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import sys
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||||
|
|
||||||
|
from cthulhu import compositor_state_i3
|
||||||
|
from cthulhu import compositor_state_types
|
||||||
|
|
||||||
|
|
||||||
|
class FakeNode:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
node_type="con",
|
||||||
|
name="",
|
||||||
|
window=None,
|
||||||
|
window_title=None,
|
||||||
|
focused=False,
|
||||||
|
children=None,
|
||||||
|
):
|
||||||
|
self.type = node_type
|
||||||
|
self.name = name
|
||||||
|
self.window = window
|
||||||
|
self.window_title = window_title
|
||||||
|
self.focused = focused
|
||||||
|
self.nodes = list(children or [])
|
||||||
|
self.floating_nodes = []
|
||||||
|
self.parent = None
|
||||||
|
for child in self.nodes:
|
||||||
|
child.parent = self
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
pending = list(self.nodes) + list(self.floating_nodes)
|
||||||
|
while pending:
|
||||||
|
node = pending.pop(0)
|
||||||
|
yield node
|
||||||
|
pending.extend(node.nodes)
|
||||||
|
pending.extend(node.floating_nodes)
|
||||||
|
|
||||||
|
def workspaces(self):
|
||||||
|
return [node for node in self if node.type == "workspace"]
|
||||||
|
|
||||||
|
def find_focused(self):
|
||||||
|
if self.focused:
|
||||||
|
return self
|
||||||
|
return next((node for node in self if node.focused), None)
|
||||||
|
|
||||||
|
def workspace(self):
|
||||||
|
node = self
|
||||||
|
while node is not None and node.type != "workspace":
|
||||||
|
node = node.parent
|
||||||
|
return node
|
||||||
|
|
||||||
|
|
||||||
|
class FakeWorkspaceReply:
|
||||||
|
def __init__(self, name, focused):
|
||||||
|
self.name = name
|
||||||
|
self.focused = focused
|
||||||
|
|
||||||
|
|
||||||
|
class FakeConnection:
|
||||||
|
def __init__(self, tree, workspaces):
|
||||||
|
self.tree = tree
|
||||||
|
self.workspaceReplies = workspaces
|
||||||
|
|
||||||
|
def get_tree(self):
|
||||||
|
return self.tree
|
||||||
|
|
||||||
|
def get_workspaces(self):
|
||||||
|
return self.workspaceReplies
|
||||||
|
|
||||||
|
|
||||||
|
class I3WorkspaceBackendRegressionTests(unittest.TestCase):
|
||||||
|
def test_query_reports_focused_window_id_title_and_nonempty_workspace(self):
|
||||||
|
focusedWindow = FakeNode(
|
||||||
|
name="AT-SPI misses this title",
|
||||||
|
window=0x200000C,
|
||||||
|
window_title="Accurate i3 title",
|
||||||
|
focused=True,
|
||||||
|
)
|
||||||
|
workspace = FakeNode(
|
||||||
|
node_type="workspace",
|
||||||
|
name="8",
|
||||||
|
children=[focusedWindow],
|
||||||
|
)
|
||||||
|
root = FakeNode(node_type="root", children=[workspace])
|
||||||
|
connection = FakeConnection(root, [FakeWorkspaceReply("8", True)])
|
||||||
|
backend = compositor_state_i3.I3WorkspaceBackend(i3ipc_module=types.SimpleNamespace())
|
||||||
|
backend._connection = connection
|
||||||
|
|
||||||
|
workspaceIds, payload = backend._query_context()
|
||||||
|
|
||||||
|
self.assertEqual(workspaceIds, {"8"})
|
||||||
|
self.assertEqual(payload["focused_window_id"], 0x200000C)
|
||||||
|
self.assertEqual(payload["focused_window_title"], "Accurate i3 title")
|
||||||
|
self.assertFalse(payload["focused_workspace_empty"])
|
||||||
|
|
||||||
|
def test_query_reports_confirmed_empty_focused_workspace(self):
|
||||||
|
workspace = FakeNode(
|
||||||
|
node_type="workspace",
|
||||||
|
name="9",
|
||||||
|
focused=True,
|
||||||
|
)
|
||||||
|
root = FakeNode(node_type="root", children=[workspace])
|
||||||
|
connection = FakeConnection(root, [FakeWorkspaceReply("9", True)])
|
||||||
|
backend = compositor_state_i3.I3WorkspaceBackend(i3ipc_module=types.SimpleNamespace())
|
||||||
|
backend._connection = connection
|
||||||
|
|
||||||
|
workspaceIds, payload = backend._query_context()
|
||||||
|
|
||||||
|
self.assertEqual(workspaceIds, {"9"})
|
||||||
|
self.assertIsNone(payload["focused_window_id"])
|
||||||
|
self.assertEqual(payload["focused_window_title"], "")
|
||||||
|
self.assertTrue(payload["focused_workspace_empty"])
|
||||||
|
|
||||||
|
def test_refresh_emits_started_state_and_finished_with_foreground_payload(self):
|
||||||
|
backend = compositor_state_i3.I3WorkspaceBackend(i3ipc_module=types.SimpleNamespace())
|
||||||
|
backend._active = True
|
||||||
|
backend._emitSignal = mock.Mock()
|
||||||
|
payload = {
|
||||||
|
"focused_window_id": None,
|
||||||
|
"focused_window_title": "",
|
||||||
|
"focused_workspace_empty": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
with mock.patch.object(backend, "_query_context", return_value=({"9"}, payload)):
|
||||||
|
self.assertFalse(backend._refresh_context("workspace-focus", transition=True))
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
backend._emitSignal.call_args_list,
|
||||||
|
[
|
||||||
|
mock.call(
|
||||||
|
compositor_state_types.DESKTOP_TRANSITION_STARTED,
|
||||||
|
{"9"},
|
||||||
|
"workspace-focus",
|
||||||
|
payload,
|
||||||
|
),
|
||||||
|
mock.call(
|
||||||
|
compositor_state_types.WORKSPACE_STATE_CHANGED,
|
||||||
|
{"9"},
|
||||||
|
"workspace-focus",
|
||||||
|
payload,
|
||||||
|
),
|
||||||
|
mock.call(
|
||||||
|
compositor_state_types.DESKTOP_TRANSITION_FINISHED,
|
||||||
|
{"9"},
|
||||||
|
"workspace-focus",
|
||||||
|
payload,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_public_refresh_emits_updated_context_without_transition(self) -> None:
|
||||||
|
backend = compositor_state_i3.I3WorkspaceBackend(i3ipc_module=types.SimpleNamespace())
|
||||||
|
backend._active = True
|
||||||
|
backend._emitSignal = mock.Mock()
|
||||||
|
payload = {
|
||||||
|
"focused_window_id": 0x2600003,
|
||||||
|
"focused_window_title": "Browser",
|
||||||
|
"focused_workspace_empty": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
with mock.patch.object(backend, "_query_context", return_value=({"8"}, payload)):
|
||||||
|
backend.refresh_context("xterm-handoff-check")
|
||||||
|
|
||||||
|
backend._emitSignal.assert_called_once_with(
|
||||||
|
compositor_state_types.WORKSPACE_STATE_CHANGED,
|
||||||
|
{"8"},
|
||||||
|
"xterm-handoff-check",
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_query_failure_emits_unknown_instead_of_preserving_stale_i3_context(self):
|
||||||
|
backend = compositor_state_i3.I3WorkspaceBackend(i3ipc_module=types.SimpleNamespace())
|
||||||
|
backend._active = True
|
||||||
|
backend._emitSignal = mock.Mock()
|
||||||
|
backend._lastContext = (frozenset({"8"}), 0x200000C, "Browser", False)
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch.object(backend, "_query_context", side_effect=OSError("IPC disconnected")),
|
||||||
|
mock.patch.object(compositor_state_i3.debug, "printMessage"),
|
||||||
|
):
|
||||||
|
self.assertFalse(backend._refresh_context("window-focus", transition=True))
|
||||||
|
|
||||||
|
backend._emitSignal.assert_called_once_with(
|
||||||
|
compositor_state_types.WORKSPACE_STATE_CHANGED,
|
||||||
|
set(),
|
||||||
|
"window-focus: i3-context-unknown",
|
||||||
|
{
|
||||||
|
"focused_window_id": None,
|
||||||
|
"focused_window_title": "",
|
||||||
|
"focused_workspace_empty": None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -64,6 +64,16 @@ class EventManagerCompositorContextRegressionTests(unittest.TestCase):
|
|||||||
|
|
||||||
adapter.add_listener.assert_called_once_with(self.manager._handle_compositor_signal)
|
adapter.add_listener.assert_called_once_with(self.manager._handle_compositor_signal)
|
||||||
|
|
||||||
|
def test_set_compositor_state_adapter_updates_active_input_manager(self) -> None:
|
||||||
|
adapter = mock.Mock()
|
||||||
|
self.manager._inputEventManager = mock.Mock()
|
||||||
|
|
||||||
|
self.manager.set_compositor_state_adapter(adapter)
|
||||||
|
|
||||||
|
self.manager._inputEventManager.set_compositor_state_adapter.assert_called_once_with(
|
||||||
|
adapter
|
||||||
|
)
|
||||||
|
|
||||||
def test_pause_signal_updates_churn_state_and_resume_clears_it(self) -> None:
|
def test_pause_signal_updates_churn_state_and_resume_clears_it(self) -> None:
|
||||||
snapshot = compositor_state_types.DesktopContextSnapshot(session_type="wayland")
|
snapshot = compositor_state_types.DesktopContextSnapshot(session_type="wayland")
|
||||||
|
|
||||||
@@ -91,6 +101,75 @@ class EventManagerCompositorContextRegressionTests(unittest.TestCase):
|
|||||||
self.assertFalse(cthulhu_state.pauseAtspiChurn)
|
self.assertFalse(cthulhu_state.pauseAtspiChurn)
|
||||||
self.assertFalse(self.manager._churnSuppressed)
|
self.assertFalse(self.manager._churnSuppressed)
|
||||||
|
|
||||||
|
def test_confirmed_empty_i3_workspace_clears_stale_web_context(self) -> None:
|
||||||
|
staleScript = mock.Mock()
|
||||||
|
defaultScript = mock.Mock()
|
||||||
|
focusManager = mock.Mock()
|
||||||
|
self.manager.app.scriptManager.get_active_script.return_value = staleScript
|
||||||
|
self.manager.app.scriptManager.get_default_script.return_value = defaultScript
|
||||||
|
snapshot = compositor_state_types.DesktopContextSnapshot(
|
||||||
|
session_type="x11",
|
||||||
|
backend_name="i3-ipc",
|
||||||
|
active_workspace_ids=frozenset({"9"}),
|
||||||
|
focused_workspace_empty=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with mock.patch.object(
|
||||||
|
event_manager.focus_manager,
|
||||||
|
"get_manager",
|
||||||
|
return_value=focusManager,
|
||||||
|
):
|
||||||
|
self.manager._handle_compositor_signal(
|
||||||
|
compositor_state_types.CompositorStateEvent(
|
||||||
|
compositor_state_types.DESKTOP_TRANSITION_FINISHED,
|
||||||
|
reason="workspace-focus",
|
||||||
|
snapshot=snapshot,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
staleScript.presentationInterrupt.assert_called_once_with()
|
||||||
|
focusManager.clear_state.assert_called_once_with("confirmed empty i3 workspace")
|
||||||
|
self.manager.app.scriptManager.set_active_script.assert_called_once_with(
|
||||||
|
defaultScript,
|
||||||
|
"focus: confirmed-empty-i3-workspace",
|
||||||
|
)
|
||||||
|
self.assertTrue(self.manager._desktopContextConfirmedEmpty)
|
||||||
|
|
||||||
|
def test_atspi_event_is_ignored_while_i3_workspace_is_confirmed_empty(self) -> None:
|
||||||
|
self.manager._desktopContextConfirmedEmpty = True
|
||||||
|
event = FakeEvent("object:state-changed:focused", source=object(), detail1=1)
|
||||||
|
self.manager._get_scriptForEvent = mock.Mock()
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch.object(event_manager.debug, "printObjectEvent"),
|
||||||
|
mock.patch.object(event_manager.debug, "printMessage"),
|
||||||
|
):
|
||||||
|
self.manager._processObjectEvent(event)
|
||||||
|
|
||||||
|
self.manager._get_scriptForEvent.assert_not_called()
|
||||||
|
|
||||||
|
def test_nonempty_i3_workspace_schedules_focus_resync_after_empty_workspace(self) -> None:
|
||||||
|
self.manager._desktopContextConfirmedEmpty = True
|
||||||
|
snapshot = compositor_state_types.DesktopContextSnapshot(
|
||||||
|
session_type="x11",
|
||||||
|
backend_name="i3-ipc",
|
||||||
|
active_workspace_ids=frozenset({"8"}),
|
||||||
|
focused_window_id=0x200000C,
|
||||||
|
focused_workspace_empty=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
with mock.patch.object(event_manager.GLib, "idle_add") as idleAdd:
|
||||||
|
self.manager._handle_compositor_signal(
|
||||||
|
compositor_state_types.CompositorStateEvent(
|
||||||
|
compositor_state_types.WORKSPACE_STATE_CHANGED,
|
||||||
|
reason="window-focus",
|
||||||
|
snapshot=snapshot,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(self.manager._desktopContextConfirmedEmpty)
|
||||||
|
idleAdd.assert_called_once_with(self.manager._sync_focus_on_startup)
|
||||||
|
|
||||||
def test_stale_context_event_is_obsolete_while_churn_is_paused(self) -> None:
|
def test_stale_context_event_is_obsolete_while_churn_is_paused(self) -> None:
|
||||||
self.manager._churnSuppressed = True
|
self.manager._churnSuppressed = True
|
||||||
self.manager._prioritizedContextToken = "current"
|
self.manager._prioritizedContextToken = "current"
|
||||||
@@ -117,6 +196,64 @@ class EventManagerCompositorContextRegressionTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(list(self.manager._eventQueue.queue), [currentEvent])
|
self.assertEqual(list(self.manager._eventQueue.queue), [currentEvent])
|
||||||
|
|
||||||
|
def test_flush_classifies_outside_queue_lock_and_preserves_nested_event(self) -> None:
|
||||||
|
staleEvent = FakeEvent("object:children-changed:add", source="stale")
|
||||||
|
currentEvent = FakeEvent("object:children-changed:add", source="current")
|
||||||
|
nestedEvent = FakeEvent("object:state-changed:focused", source="nested", detail1=1)
|
||||||
|
self.manager._eventQueue.put(staleEvent)
|
||||||
|
self.manager._eventQueue.put(currentEvent)
|
||||||
|
self.manager._ignore = mock.Mock(return_value=False)
|
||||||
|
self.manager._prioritizeSelfHostedFocusedEvent = mock.Mock(return_value=False)
|
||||||
|
self.manager._queuePrintln = mock.Mock()
|
||||||
|
self.manager._inFlood = mock.Mock(return_value=False)
|
||||||
|
self.manager._shouldSuspendEventsFor = mock.Mock(return_value=False)
|
||||||
|
self.manager._dequeue = mock.Mock(return_value=False)
|
||||||
|
script = types.SimpleNamespace(eventCache={})
|
||||||
|
|
||||||
|
def classify_event(event) -> bool:
|
||||||
|
self.assertFalse(self.manager._gidleLock.locked())
|
||||||
|
if event is staleEvent:
|
||||||
|
self.manager._enqueue(nestedEvent)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.manager._event_is_from_stale_context = mock.Mock(side_effect=classify_event)
|
||||||
|
self.manager._should_preserve_during_suppression = mock.Mock(return_value=False)
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch.object(event_manager.AXObject, "get_application", return_value=object()),
|
||||||
|
mock.patch.object(event_manager.GLib, "idle_add", return_value=1),
|
||||||
|
mock.patch.object(
|
||||||
|
event_manager.cthulhu.cthulhuApp.scriptManager,
|
||||||
|
"get_script",
|
||||||
|
return_value=script,
|
||||||
|
),
|
||||||
|
mock.patch.object(
|
||||||
|
event_manager.AXUtilities,
|
||||||
|
"get_application_toolkit_name",
|
||||||
|
return_value="VCL",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
self.manager._flush_stale_atspi_events()
|
||||||
|
|
||||||
|
self.assertEqual(list(self.manager._eventQueue.queue), [currentEvent, nestedEvent])
|
||||||
|
self.manager._dequeue.assert_not_called()
|
||||||
|
|
||||||
|
def test_flush_restores_retained_and_unexamined_events_after_exception(self) -> None:
|
||||||
|
firstEvent = FakeEvent("object:children-changed:add", source="first")
|
||||||
|
secondEvent = FakeEvent("object:children-changed:add", source="second")
|
||||||
|
self.manager._eventQueue.put(firstEvent)
|
||||||
|
self.manager._eventQueue.put(secondEvent)
|
||||||
|
self.manager._event_is_from_stale_context = mock.Mock(
|
||||||
|
side_effect=RuntimeError("property lookup failed"),
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "property lookup failed"):
|
||||||
|
self.manager._flush_stale_atspi_events()
|
||||||
|
|
||||||
|
self.assertEqual(list(self.manager._eventQueue.queue), [firstEvent, secondEvent])
|
||||||
|
self.assertFalse(self.manager._flushingStaleAtspiEvents)
|
||||||
|
|
||||||
def test_stale_background_event_does_not_activate_script_during_suppression(self) -> None:
|
def test_stale_background_event_does_not_activate_script_during_suppression(self) -> None:
|
||||||
script = mock.Mock()
|
script = mock.Mock()
|
||||||
script.isActivatableEvent.return_value = True
|
script.isActivatableEvent.return_value = True
|
||||||
|
|||||||
@@ -6,9 +6,20 @@ from unittest import mock
|
|||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||||
|
|
||||||
from cthulhu import input_event_manager
|
from cthulhu import input_event_manager
|
||||||
|
from cthulhu import compositor_state_types
|
||||||
|
|
||||||
|
|
||||||
class InputEventManagerX11FocusRegressionTests(unittest.TestCase):
|
class InputEventManagerX11FocusRegressionTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.compositorSnapshot = input_event_manager.cthulhu_state.compositorSnapshot
|
||||||
|
input_event_manager.cthulhu_state.compositorSnapshot = None
|
||||||
|
self.addCleanup(
|
||||||
|
setattr,
|
||||||
|
input_event_manager.cthulhu_state,
|
||||||
|
"compositorSnapshot",
|
||||||
|
self.compositorSnapshot,
|
||||||
|
)
|
||||||
|
|
||||||
def test_active_x11_window_differs_from_cached_atspi_window_by_pid(self):
|
def test_active_x11_window_differs_from_cached_atspi_window_by_pid(self):
|
||||||
manager = input_event_manager.InputEventManager()
|
manager = input_event_manager.InputEventManager()
|
||||||
cachedWindow = object()
|
cachedWindow = object()
|
||||||
@@ -599,7 +610,6 @@ class InputEventManagerX11FocusRegressionTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_xterm_match_reuses_cached_metadata_while_ewmh_window_id_is_unchanged(self):
|
def test_xterm_match_reuses_cached_metadata_while_ewmh_window_id_is_unchanged(self):
|
||||||
manager = input_event_manager.InputEventManager()
|
manager = input_event_manager.InputEventManager()
|
||||||
activeWindow = object()
|
|
||||||
|
|
||||||
with (
|
with (
|
||||||
mock.patch.object(
|
mock.patch.object(
|
||||||
@@ -609,12 +619,7 @@ class InputEventManagerX11FocusRegressionTests(unittest.TestCase):
|
|||||||
),
|
),
|
||||||
mock.patch.object(
|
mock.patch.object(
|
||||||
manager,
|
manager,
|
||||||
"_get_active_x11_window",
|
"_x11_window_id_xterm_match",
|
||||||
return_value=activeWindow,
|
|
||||||
) as getActiveWindow,
|
|
||||||
mock.patch.object(
|
|
||||||
manager,
|
|
||||||
"_x11_window_xterm_match",
|
|
||||||
return_value=False,
|
return_value=False,
|
||||||
) as matcher,
|
) as matcher,
|
||||||
):
|
):
|
||||||
@@ -623,8 +628,206 @@ class InputEventManagerX11FocusRegressionTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertFalse(firstResult)
|
self.assertFalse(firstResult)
|
||||||
self.assertFalse(secondResult)
|
self.assertFalse(secondResult)
|
||||||
getActiveWindow.assert_called_once_with(ewmhWindowId=0x1400010)
|
matcher.assert_called_once_with(0x1400010)
|
||||||
matcher.assert_called_once_with(activeWindow)
|
|
||||||
|
def test_xterm_match_prefers_i3_focused_window_id_over_stale_ewmh_id(self):
|
||||||
|
manager = input_event_manager.InputEventManager()
|
||||||
|
snapshot = compositor_state_types.DesktopContextSnapshot(
|
||||||
|
session_type="x11",
|
||||||
|
backend_name="i3-ipc",
|
||||||
|
focused_window_id=0x2600003,
|
||||||
|
focused_workspace_empty=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch.object(input_event_manager.cthulhu_state, "compositorSnapshot", snapshot),
|
||||||
|
mock.patch.object(
|
||||||
|
manager,
|
||||||
|
"_get_ewmh_active_x11_window_id",
|
||||||
|
return_value=0x200000C,
|
||||||
|
) as getEwmhWindowId,
|
||||||
|
mock.patch.object(
|
||||||
|
manager,
|
||||||
|
"_x11_window_id_xterm_match",
|
||||||
|
return_value=False,
|
||||||
|
) as matcher,
|
||||||
|
):
|
||||||
|
result = manager._active_x11_window_xterm_match()
|
||||||
|
|
||||||
|
self.assertFalse(result)
|
||||||
|
matcher.assert_called_once_with(0x2600003)
|
||||||
|
getEwmhWindowId.assert_not_called()
|
||||||
|
|
||||||
|
def test_xterm_match_refreshes_i3_context_before_reusing_cached_match(self) -> None:
|
||||||
|
manager = input_event_manager.InputEventManager()
|
||||||
|
staleSnapshot = compositor_state_types.DesktopContextSnapshot(
|
||||||
|
session_type="x11",
|
||||||
|
backend_name="i3-ipc",
|
||||||
|
focused_window_id=0x200000C,
|
||||||
|
focused_workspace_empty=False,
|
||||||
|
)
|
||||||
|
freshSnapshot = compositor_state_types.DesktopContextSnapshot(
|
||||||
|
session_type="x11",
|
||||||
|
backend_name="i3-ipc",
|
||||||
|
focused_window_id=0x2600003,
|
||||||
|
focused_workspace_empty=False,
|
||||||
|
)
|
||||||
|
adapter = mock.Mock()
|
||||||
|
|
||||||
|
def refresh_context(_reason: str) -> compositor_state_types.DesktopContextSnapshot:
|
||||||
|
input_event_manager.cthulhu_state.compositorSnapshot = freshSnapshot
|
||||||
|
return freshSnapshot
|
||||||
|
|
||||||
|
adapter.refresh_workspace_context.side_effect = refresh_context
|
||||||
|
input_event_manager.cthulhu_state.compositorSnapshot = staleSnapshot
|
||||||
|
manager._lastEwmhActiveWindowId = 0x200000C
|
||||||
|
manager._lastEwmhXtermMatch = True
|
||||||
|
|
||||||
|
manager.set_compositor_state_adapter(adapter)
|
||||||
|
with mock.patch.object(
|
||||||
|
manager,
|
||||||
|
"_x11_window_id_xterm_match",
|
||||||
|
return_value=False,
|
||||||
|
) as matcher:
|
||||||
|
result = manager._active_x11_window_xterm_match()
|
||||||
|
|
||||||
|
self.assertFalse(result)
|
||||||
|
adapter.refresh_workspace_context.assert_called_once_with("xterm-handoff-check")
|
||||||
|
matcher.assert_called_once_with(0x2600003)
|
||||||
|
|
||||||
|
def test_i3_exact_xid_initializes_x11_before_matching_xterm(self):
|
||||||
|
manager = input_event_manager.InputEventManager()
|
||||||
|
snapshot = compositor_state_types.DesktopContextSnapshot(
|
||||||
|
session_type="x11",
|
||||||
|
backend_name="i3-ipc",
|
||||||
|
focused_window_id=0x200000C,
|
||||||
|
focused_workspace_empty=False,
|
||||||
|
)
|
||||||
|
window = mock.Mock()
|
||||||
|
window.get_wm_class.return_value = ("xterm", "XTerm")
|
||||||
|
window.get_wm_name.return_value = "devel"
|
||||||
|
window.get_full_property.return_value = None
|
||||||
|
display = mock.Mock()
|
||||||
|
display.create_resource_object.return_value = window
|
||||||
|
|
||||||
|
def initializeX11():
|
||||||
|
manager._x11Display = display
|
||||||
|
manager._x11AnyPropertyType = object()
|
||||||
|
return True
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch.object(input_event_manager.cthulhu_state, "compositorSnapshot", snapshot),
|
||||||
|
mock.patch.object(
|
||||||
|
manager,
|
||||||
|
"_ensure_x11_display",
|
||||||
|
side_effect=initializeX11,
|
||||||
|
) as ensureX11,
|
||||||
|
mock.patch.object(manager, "_get_ewmh_active_x11_window_id") as getEwmhWindowId,
|
||||||
|
):
|
||||||
|
result = manager._active_x11_window_xterm_match()
|
||||||
|
|
||||||
|
self.assertTrue(result)
|
||||||
|
ensureX11.assert_called_once_with()
|
||||||
|
display.create_resource_object.assert_called_once_with("window", 0x200000C)
|
||||||
|
getEwmhWindowId.assert_not_called()
|
||||||
|
|
||||||
|
def test_confirmed_empty_i3_workspace_is_definitely_not_xterm(self):
|
||||||
|
manager = input_event_manager.InputEventManager()
|
||||||
|
manager._lastEwmhActiveWindowId = 0x200000C
|
||||||
|
manager._lastEwmhXtermMatch = True
|
||||||
|
snapshot = compositor_state_types.DesktopContextSnapshot(
|
||||||
|
session_type="x11",
|
||||||
|
backend_name="i3-ipc",
|
||||||
|
focused_workspace_empty=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch.object(input_event_manager.cthulhu_state, "compositorSnapshot", snapshot),
|
||||||
|
mock.patch.object(manager, "_get_ewmh_active_x11_window_id") as getEwmhWindowId,
|
||||||
|
mock.patch.object(manager, "_x11_window_id_xterm_match") as matcher,
|
||||||
|
):
|
||||||
|
result = manager._active_x11_window_xterm_match()
|
||||||
|
|
||||||
|
self.assertFalse(result)
|
||||||
|
self.assertIsNone(manager._lastEwmhActiveWindowId)
|
||||||
|
self.assertFalse(manager._lastEwmhXtermMatch)
|
||||||
|
getEwmhWindowId.assert_not_called()
|
||||||
|
matcher.assert_not_called()
|
||||||
|
|
||||||
|
def test_unknown_i3_context_falls_back_to_generic_ewmh_window_id(self):
|
||||||
|
manager = input_event_manager.InputEventManager()
|
||||||
|
snapshot = compositor_state_types.DesktopContextSnapshot(
|
||||||
|
session_type="x11",
|
||||||
|
backend_name="i3-ipc",
|
||||||
|
focused_window_id=None,
|
||||||
|
focused_workspace_empty=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch.object(input_event_manager.cthulhu_state, "compositorSnapshot", snapshot),
|
||||||
|
mock.patch.object(
|
||||||
|
manager,
|
||||||
|
"_get_ewmh_active_x11_window_id",
|
||||||
|
return_value=0x2600003,
|
||||||
|
),
|
||||||
|
mock.patch.object(
|
||||||
|
manager,
|
||||||
|
"_x11_window_id_xterm_match",
|
||||||
|
return_value=False,
|
||||||
|
) as matcher,
|
||||||
|
):
|
||||||
|
result = manager._active_x11_window_xterm_match()
|
||||||
|
|
||||||
|
self.assertFalse(result)
|
||||||
|
matcher.assert_called_once_with(0x2600003)
|
||||||
|
|
||||||
|
def test_ewmh_brave_window_does_not_use_stale_wnck_xterm_metadata(self):
|
||||||
|
manager = input_event_manager.InputEventManager()
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch.object(
|
||||||
|
manager,
|
||||||
|
"_get_ewmh_active_x11_window_id",
|
||||||
|
return_value=0x2600003,
|
||||||
|
),
|
||||||
|
mock.patch.object(
|
||||||
|
manager,
|
||||||
|
"_x11_window_id_xterm_match",
|
||||||
|
return_value=False,
|
||||||
|
) as directMatcher,
|
||||||
|
mock.patch.object(manager, "_get_active_x11_window") as getWnckWindow,
|
||||||
|
mock.patch.object(manager, "_x11_window_xterm_match") as wnckMatcher,
|
||||||
|
):
|
||||||
|
result = manager._active_x11_window_xterm_match()
|
||||||
|
|
||||||
|
self.assertFalse(result)
|
||||||
|
directMatcher.assert_called_once_with(0x2600003)
|
||||||
|
getWnckWindow.assert_not_called()
|
||||||
|
wnckMatcher.assert_not_called()
|
||||||
|
|
||||||
|
def test_direct_x11_metadata_identifies_brave_as_non_xterm(self):
|
||||||
|
manager = input_event_manager.InputEventManager()
|
||||||
|
|
||||||
|
with mock.patch.object(
|
||||||
|
manager,
|
||||||
|
"_get_x11_window_metadata",
|
||||||
|
return_value=(["brave-origin", "Brave-origin", "New Tab - Brave Origin"], 1234),
|
||||||
|
):
|
||||||
|
result = manager._x11_window_id_xterm_match(0x2600003)
|
||||||
|
|
||||||
|
self.assertFalse(result)
|
||||||
|
|
||||||
|
def test_direct_x11_metadata_identifies_xterm(self):
|
||||||
|
manager = input_event_manager.InputEventManager()
|
||||||
|
|
||||||
|
with mock.patch.object(
|
||||||
|
manager,
|
||||||
|
"_get_x11_window_metadata",
|
||||||
|
return_value=(["xterm", "XTerm", "devel"], 1234),
|
||||||
|
):
|
||||||
|
result = manager._x11_window_id_xterm_match(0x200000C)
|
||||||
|
|
||||||
|
self.assertTrue(result)
|
||||||
|
|
||||||
def test_xterm_focus_change_suspends_grabs_without_keyboard_event(self):
|
def test_xterm_focus_change_suspends_grabs_without_keyboard_event(self):
|
||||||
manager = input_event_manager.InputEventManager()
|
manager = input_event_manager.InputEventManager()
|
||||||
@@ -687,9 +890,67 @@ class InputEventManagerX11FocusRegressionTests(unittest.TestCase):
|
|||||||
result = manager._poll_xterm_grab_recovery()
|
result = manager._poll_xterm_grab_recovery()
|
||||||
|
|
||||||
self.assertTrue(result)
|
self.assertTrue(result)
|
||||||
|
self.assertEqual(manager._xtermRecoveryPollCount, 1)
|
||||||
restore.assert_called_once_with()
|
restore.assert_called_once_with()
|
||||||
suspend.assert_not_called()
|
suspend.assert_not_called()
|
||||||
|
|
||||||
|
def test_xterm_match_records_last_raw_ewmh_window_id(self):
|
||||||
|
manager = input_event_manager.InputEventManager()
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch.object(
|
||||||
|
manager,
|
||||||
|
"_get_ewmh_active_x11_window_id",
|
||||||
|
return_value=0x1400010,
|
||||||
|
),
|
||||||
|
mock.patch.object(
|
||||||
|
manager,
|
||||||
|
"_x11_window_id_xterm_match",
|
||||||
|
return_value=False,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = manager._active_x11_window_xterm_match()
|
||||||
|
|
||||||
|
self.assertFalse(result)
|
||||||
|
self.assertEqual(manager._lastEwmhReadWindowId, 0x1400010)
|
||||||
|
|
||||||
|
def test_xterm_focus_poll_survives_transient_matcher_failure(self):
|
||||||
|
manager = input_event_manager.InputEventManager()
|
||||||
|
manager._device = object()
|
||||||
|
manager._xtermRecoverySourceId = 23
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch.object(
|
||||||
|
manager,
|
||||||
|
"_active_x11_window_xterm_match",
|
||||||
|
side_effect=RuntimeError("transient matcher failure"),
|
||||||
|
),
|
||||||
|
mock.patch.object(input_event_manager.debug, "print_message") as printMessage,
|
||||||
|
):
|
||||||
|
result = manager._poll_xterm_grab_recovery()
|
||||||
|
|
||||||
|
self.assertTrue(result)
|
||||||
|
self.assertEqual(manager._xtermRecoverySourceId, 23)
|
||||||
|
printMessage.assert_called_once()
|
||||||
|
|
||||||
|
def test_ewmh_read_failure_reconnects_on_next_check(self):
|
||||||
|
manager = input_event_manager.InputEventManager()
|
||||||
|
x11Display = mock.Mock()
|
||||||
|
x11Root = mock.Mock()
|
||||||
|
x11Root.get_full_property.side_effect = RuntimeError("display connection failed")
|
||||||
|
manager._x11Display = x11Display
|
||||||
|
manager._x11Root = x11Root
|
||||||
|
manager._netActiveWindowAtom = object()
|
||||||
|
manager._x11AnyPropertyType = object()
|
||||||
|
manager._didAttemptX11Display = True
|
||||||
|
|
||||||
|
with mock.patch.object(input_event_manager.debug, "print_message"):
|
||||||
|
result = manager._get_ewmh_active_x11_window_id()
|
||||||
|
|
||||||
|
self.assertIsNone(result)
|
||||||
|
x11Display.close.assert_called_once_with()
|
||||||
|
self.assertFalse(manager._didAttemptX11Display)
|
||||||
|
|
||||||
def test_xterm_suppression_does_not_check_x11_without_active_handoff(self):
|
def test_xterm_suppression_does_not_check_x11_without_active_handoff(self):
|
||||||
manager = input_event_manager.InputEventManager()
|
manager = input_event_manager.InputEventManager()
|
||||||
|
|
||||||
|
|||||||
@@ -82,12 +82,23 @@ class RuntimeStateSnapshotTests(unittest.TestCase):
|
|||||||
manager._scriptWithSuspendedGrabsForXterm = object()
|
manager._scriptWithSuspendedGrabsForXterm = object()
|
||||||
manager._xtermFocusHandlerId = 17
|
manager._xtermFocusHandlerId = 17
|
||||||
manager._xtermRecoverySourceId = 23
|
manager._xtermRecoverySourceId = 23
|
||||||
|
manager._xtermRecoveryPollCount = 42
|
||||||
|
manager._xtermHandoffHistory = [
|
||||||
|
"ewmh:0x200000c->0x1400010",
|
||||||
|
"restore:success",
|
||||||
|
]
|
||||||
|
manager._lastEwmhReadWindowId = 0x1400010
|
||||||
manager._lastEwmhActiveWindowId = 0x1400010
|
manager._lastEwmhActiveWindowId = 0x1400010
|
||||||
manager._lastEwmhXtermMatch = False
|
manager._lastEwmhXtermMatch = False
|
||||||
manager._last_input_event = object()
|
manager._last_input_event = object()
|
||||||
manager._last_non_modifier_key_event = None
|
manager._last_non_modifier_key_event = None
|
||||||
|
|
||||||
snapshot = manager.get_debug_snapshot()
|
with mock.patch.object(
|
||||||
|
manager,
|
||||||
|
"_xterm_recovery_source_is_present",
|
||||||
|
return_value=True,
|
||||||
|
):
|
||||||
|
snapshot = manager.get_debug_snapshot()
|
||||||
|
|
||||||
self.assertTrue(snapshot["device_active"])
|
self.assertTrue(snapshot["device_active"])
|
||||||
self.assertTrue(snapshot["pointer_watcher_active"])
|
self.assertTrue(snapshot["pointer_watcher_active"])
|
||||||
@@ -97,6 +108,13 @@ class RuntimeStateSnapshotTests(unittest.TestCase):
|
|||||||
self.assertTrue(snapshot["suspended_xterm_script_present"])
|
self.assertTrue(snapshot["suspended_xterm_script_present"])
|
||||||
self.assertTrue(snapshot["xterm_focus_monitor_active"])
|
self.assertTrue(snapshot["xterm_focus_monitor_active"])
|
||||||
self.assertTrue(snapshot["xterm_recovery_timer_active"])
|
self.assertTrue(snapshot["xterm_recovery_timer_active"])
|
||||||
|
self.assertTrue(snapshot["xterm_recovery_source_present"])
|
||||||
|
self.assertEqual(42, snapshot["xterm_recovery_poll_count"])
|
||||||
|
self.assertEqual(
|
||||||
|
"ewmh:0x200000c->0x1400010 | restore:success",
|
||||||
|
snapshot["xterm_handoff_history"],
|
||||||
|
)
|
||||||
|
self.assertEqual("0x1400010", snapshot["last_ewmh_read_window_id"])
|
||||||
self.assertEqual("0x1400010", snapshot["last_ewmh_active_window_id"])
|
self.assertEqual("0x1400010", snapshot["last_ewmh_active_window_id"])
|
||||||
self.assertFalse(snapshot["last_ewmh_xterm_match"])
|
self.assertFalse(snapshot["last_ewmh_xterm_match"])
|
||||||
self.assertFalse(snapshot["last_non_modifier_key_event_present"])
|
self.assertFalse(snapshot["last_non_modifier_key_event_present"])
|
||||||
|
|||||||
@@ -622,6 +622,60 @@ class WebCaretMovedRegressionTests(unittest.TestCase):
|
|||||||
setLocusOfFocus.assert_not_called()
|
setLocusOfFocus.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class WebMultilineEditorLinePresentationRegressionTests(unittest.TestCase):
|
||||||
|
def test_native_multiline_editor_uses_visual_web_line(self):
|
||||||
|
testScript = web_script.Script.__new__(web_script.Script)
|
||||||
|
testScript._lastCommandWasCaretNav = False
|
||||||
|
testScript._lastCommandWasStructNav = False
|
||||||
|
testScript.pointOfReference = {}
|
||||||
|
testScript.utilities = mock.Mock()
|
||||||
|
testScript.utilities.isContentEditableWithEmbeddedObjects.return_value = False
|
||||||
|
testScript.utilities.inDocumentContent.return_value = True
|
||||||
|
testScript.utilities.getTopLevelDocumentForObject.return_value = "document"
|
||||||
|
testScript.utilities.getPriorContext.return_value = ("prior", 20)
|
||||||
|
testScript.utilities.getCaretContext.return_value = ("editor", 42)
|
||||||
|
visualLine = [["editor", 40, 55, "wrapped line"]]
|
||||||
|
testScript.utilities.getLineContentsAtOffset.return_value = visualLine
|
||||||
|
testScript.speakContents = mock.Mock()
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch.object(web_script.AXUtilities, "is_editable", return_value=True),
|
||||||
|
mock.patch.object(web_script.AXUtilities, "is_multi_line", return_value=True),
|
||||||
|
mock.patch.object(web_script.default.Script, "sayLine") as defaultSayLine,
|
||||||
|
):
|
||||||
|
web_script.Script.sayLine(testScript, "editor")
|
||||||
|
|
||||||
|
defaultSayLine.assert_not_called()
|
||||||
|
testScript.utilities.getLineContentsAtOffset.assert_called_once_with(
|
||||||
|
"editor",
|
||||||
|
42,
|
||||||
|
useCache=True,
|
||||||
|
)
|
||||||
|
testScript.speakContents.assert_called_once_with(
|
||||||
|
visualLine,
|
||||||
|
priorObj="prior",
|
||||||
|
alreadyFocused=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_native_multiline_editor_is_visual_line_boundary(self):
|
||||||
|
editor = object()
|
||||||
|
utilities = web_script_utilities.Utilities.__new__(
|
||||||
|
web_script_utilities.Utilities
|
||||||
|
)
|
||||||
|
utilities.isContentEditableWithEmbeddedObjects = mock.Mock(return_value=False)
|
||||||
|
utilities.inDocumentContent = mock.Mock(return_value=True)
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch.object(web_script_utilities.AXUtilities, "is_editable", return_value=True),
|
||||||
|
mock.patch.object(web_script_utilities.AXUtilities, "is_multi_line", return_value=True),
|
||||||
|
mock.patch.object(web_script_utilities.AXObject, "find_ancestor") as findAncestor,
|
||||||
|
):
|
||||||
|
boundary = utilities._getEditableLineBoundary(editor)
|
||||||
|
|
||||||
|
self.assertIs(boundary, editor)
|
||||||
|
findAncestor.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
class TextEventReasonRegressionTests(unittest.TestCase):
|
class TextEventReasonRegressionTests(unittest.TestCase):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _make_input_manager():
|
def _make_input_manager():
|
||||||
@@ -1804,7 +1858,7 @@ class WebDynamicContentRecoveryRegressionTests(unittest.TestCase):
|
|||||||
setLocusOfFocus.assert_called_once_with(event, source, False)
|
setLocusOfFocus.assert_called_once_with(event, source, False)
|
||||||
testScript.utilities.setCaretContext.assert_called_once_with(source, 0)
|
testScript.utilities.setCaretContext.assert_called_once_with(source, 0)
|
||||||
|
|
||||||
def test_focused_link_replaces_stale_section_context_after_caret_nav(self):
|
def test_delayed_focused_link_does_not_rewind_section_context_after_caret_nav(self):
|
||||||
testScript = self._make_dynamic_script()
|
testScript = self._make_dynamic_script()
|
||||||
document = object()
|
document = object()
|
||||||
section = object()
|
section = object()
|
||||||
@@ -1829,8 +1883,8 @@ class WebDynamicContentRecoveryRegressionTests(unittest.TestCase):
|
|||||||
result = web_script.Script.onFocusedChanged(testScript, event)
|
result = web_script.Script.onFocusedChanged(testScript, event)
|
||||||
|
|
||||||
self.assertTrue(result)
|
self.assertTrue(result)
|
||||||
setLocusOfFocus.assert_called_once_with(event, link, False)
|
setLocusOfFocus.assert_not_called()
|
||||||
testScript.utilities.setCaretContext.assert_called_once_with(link, 0)
|
testScript.utilities.setCaretContext.assert_not_called()
|
||||||
testScript.utilities.searchForCaretContext.assert_not_called()
|
testScript.utilities.searchForCaretContext.assert_not_called()
|
||||||
def test_browse_mode_sticky_blocks_web_app_descendant_focus_claim(self):
|
def test_browse_mode_sticky_blocks_web_app_descendant_focus_claim(self):
|
||||||
testScript = self._make_dynamic_script()
|
testScript = self._make_dynamic_script()
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ class WebLayoutModeLineRegressionTests(unittest.TestCase):
|
|||||||
utilities.findPreviousCaretInOrder = mock.Mock(return_value=(None, -1))
|
utilities.findPreviousCaretInOrder = mock.Mock(return_value=(None, -1))
|
||||||
utilities.findNextCaretInOrder = mock.Mock(return_value=(None, -1))
|
utilities.findNextCaretInOrder = mock.Mock(return_value=(None, -1))
|
||||||
utilities.isContentEditableWithEmbeddedObjects = mock.Mock(return_value=False)
|
utilities.isContentEditableWithEmbeddedObjects = mock.Mock(return_value=False)
|
||||||
|
utilities.inDocumentContent = mock.Mock(return_value=False)
|
||||||
return utilities, obj, contents
|
return utilities, obj, contents
|
||||||
|
|
||||||
def _get_line_for_setting(self, layoutMode):
|
def _get_line_for_setting(self, layoutMode):
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ class WindowTitleFallbackRegressionTests(unittest.TestCase):
|
|||||||
activeWindow = mock.Mock(id=200)
|
activeWindow = mock.Mock(id=200)
|
||||||
|
|
||||||
with (
|
with (
|
||||||
|
mock.patch.object(plugin, "_ensure_x11_display", return_value=True),
|
||||||
mock.patch.object(plugin, "_get_active_window", return_value=activeWindow),
|
mock.patch.object(plugin, "_get_active_window", return_value=activeWindow),
|
||||||
mock.patch.object(plugin, "_get_current_title", return_value="XTerm"),
|
mock.patch.object(plugin, "_get_current_title", return_value="XTerm"),
|
||||||
mock.patch.object(plugin, "_schedule_fallback_title") as scheduleFallback,
|
mock.patch.object(plugin, "_schedule_fallback_title") as scheduleFallback,
|
||||||
@@ -47,7 +48,10 @@ class WindowTitleFallbackRegressionTests(unittest.TestCase):
|
|||||||
plugin._pollSourceId = 1
|
plugin._pollSourceId = 1
|
||||||
plugin._lastActiveWindowId = 100
|
plugin._lastActiveWindowId = 100
|
||||||
|
|
||||||
with mock.patch.object(plugin, "_get_active_window", return_value=None):
|
with (
|
||||||
|
mock.patch.object(plugin, "_ensure_x11_display", return_value=True),
|
||||||
|
mock.patch.object(plugin, "_get_active_window", return_value=None),
|
||||||
|
):
|
||||||
self.assertTrue(plugin._poll_window_title())
|
self.assertTrue(plugin._poll_window_title())
|
||||||
|
|
||||||
self.assertEqual(plugin._lastActiveWindowId, 100)
|
self.assertEqual(plugin._lastActiveWindowId, 100)
|
||||||
@@ -58,6 +62,7 @@ class WindowTitleFallbackRegressionTests(unittest.TestCase):
|
|||||||
activeWindow = mock.Mock()
|
activeWindow = mock.Mock()
|
||||||
|
|
||||||
with (
|
with (
|
||||||
|
mock.patch.object(plugin, "_ensure_x11_display", return_value=True),
|
||||||
mock.patch.object(plugin, "_get_active_window", return_value=activeWindow),
|
mock.patch.object(plugin, "_get_active_window", return_value=activeWindow),
|
||||||
mock.patch.object(plugin, "_get_current_title", return_value="Terminal"),
|
mock.patch.object(plugin, "_get_current_title", return_value="Terminal"),
|
||||||
):
|
):
|
||||||
@@ -69,11 +74,172 @@ class WindowTitleFallbackRegressionTests(unittest.TestCase):
|
|||||||
activeWindow = mock.Mock()
|
activeWindow = mock.Mock()
|
||||||
|
|
||||||
with (
|
with (
|
||||||
|
mock.patch.object(plugin, "_ensure_x11_display", return_value=True),
|
||||||
mock.patch.object(plugin, "_get_active_window", return_value=activeWindow),
|
mock.patch.object(plugin, "_get_active_window", return_value=activeWindow),
|
||||||
mock.patch.object(plugin, "_get_current_title", return_value="Game Window"),
|
mock.patch.object(plugin, "_get_current_title", return_value="Game Window"),
|
||||||
):
|
):
|
||||||
self.assertEqual(plugin.get_fallback_title("Wine Desktop"), "Game Window")
|
self.assertEqual(plugin.get_fallback_title("Wine Desktop"), "Game Window")
|
||||||
|
|
||||||
|
def test_fallback_prefers_i3_title_and_window_id(self):
|
||||||
|
plugin = WindowTitleReader()
|
||||||
|
plugin.app = mock.Mock()
|
||||||
|
snapshot = mock.Mock(
|
||||||
|
backend_name="i3-ipc",
|
||||||
|
focused_workspace_empty=False,
|
||||||
|
focused_window_id=0x200000C,
|
||||||
|
focused_window_title="Accurate i3 title",
|
||||||
|
)
|
||||||
|
plugin.app.getCompositorStateAdapter.return_value.get_snapshot.return_value = snapshot
|
||||||
|
|
||||||
|
with mock.patch.object(plugin, "_get_active_window") as getX11Window:
|
||||||
|
self.assertEqual(plugin.get_fallback_title("Incomplete title"), "Accurate i3 title")
|
||||||
|
|
||||||
|
getX11Window.assert_not_called()
|
||||||
|
|
||||||
|
def test_i3_wine_desktop_title_uses_existing_child_window_resolution(self):
|
||||||
|
plugin = WindowTitleReader()
|
||||||
|
plugin.app = mock.Mock()
|
||||||
|
snapshot = mock.Mock(
|
||||||
|
backend_name="i3-ipc",
|
||||||
|
focused_workspace_empty=False,
|
||||||
|
focused_window_id=0x2400007,
|
||||||
|
focused_window_title="Wine Desktop",
|
||||||
|
)
|
||||||
|
desktopWindow = mock.Mock()
|
||||||
|
plugin.app.getCompositorStateAdapter.return_value.get_snapshot.return_value = snapshot
|
||||||
|
plugin._display = mock.Mock()
|
||||||
|
plugin._display.create_resource_object.return_value = desktopWindow
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch.object(plugin, "_ensure_x11_display", return_value=True),
|
||||||
|
mock.patch.object(
|
||||||
|
plugin,
|
||||||
|
"_get_window_title",
|
||||||
|
return_value="Wine Desktop",
|
||||||
|
),
|
||||||
|
mock.patch.object(
|
||||||
|
plugin,
|
||||||
|
"_get_wine_desktop_title",
|
||||||
|
return_value="Actual Game Window",
|
||||||
|
) as getWineTitle,
|
||||||
|
):
|
||||||
|
self.assertEqual(
|
||||||
|
plugin.get_fallback_title("Wine Desktop"),
|
||||||
|
"Actual Game Window",
|
||||||
|
)
|
||||||
|
|
||||||
|
plugin._display.create_resource_object.assert_called_once_with("window", 0x2400007)
|
||||||
|
getWineTitle.assert_called_once_with(desktopWindow)
|
||||||
|
|
||||||
|
def test_wine_title_resolution_ignores_unrelated_x11_focus(self):
|
||||||
|
plugin = WindowTitleReader()
|
||||||
|
desktopWindow = mock.Mock(id=10)
|
||||||
|
unrelatedFocus = mock.Mock(id=20)
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch.object(plugin, "_get_focus_window", return_value=unrelatedFocus),
|
||||||
|
mock.patch.object(
|
||||||
|
plugin,
|
||||||
|
"_window_is_descendant_of",
|
||||||
|
return_value=False,
|
||||||
|
) as isDescendant,
|
||||||
|
mock.patch.object(plugin, "_get_window_title", return_value="Unrelated Terminal"),
|
||||||
|
mock.patch.object(
|
||||||
|
plugin,
|
||||||
|
"_find_child_title",
|
||||||
|
return_value="Actual Game Window",
|
||||||
|
) as findChildTitle,
|
||||||
|
):
|
||||||
|
self.assertEqual(
|
||||||
|
plugin._get_wine_desktop_title(desktopWindow),
|
||||||
|
"Actual Game Window",
|
||||||
|
)
|
||||||
|
|
||||||
|
isDescendant.assert_called_once_with(unrelatedFocus, desktopWindow)
|
||||||
|
findChildTitle.assert_called_once_with(desktopWindow)
|
||||||
|
|
||||||
|
def test_wine_child_title_filter_rejects_infrastructure_windows(self):
|
||||||
|
plugin = WindowTitleReader()
|
||||||
|
helperWindow = mock.Mock()
|
||||||
|
helperWindow.get_wm_class.return_value = (
|
||||||
|
"cthulhu-wine-access.exe",
|
||||||
|
"cthulhu-wine-access.exe",
|
||||||
|
)
|
||||||
|
gameWindow = mock.Mock()
|
||||||
|
gameWindow.get_wm_class.return_value = ("game.exe", "game.exe")
|
||||||
|
|
||||||
|
self.assertFalse(plugin._is_usable_wine_child_title(gameWindow, "Default IME"))
|
||||||
|
self.assertFalse(plugin._is_usable_wine_child_title(helperWindow, "NVDA"))
|
||||||
|
self.assertTrue(plugin._is_usable_wine_child_title(gameWindow, "Actual Game Window"))
|
||||||
|
|
||||||
|
def test_confirmed_empty_i3_workspace_does_not_fall_back_to_stale_x11_title(self):
|
||||||
|
plugin = WindowTitleReader()
|
||||||
|
plugin.app = mock.Mock()
|
||||||
|
snapshot = mock.Mock(
|
||||||
|
backend_name="i3-ipc",
|
||||||
|
focused_workspace_empty=True,
|
||||||
|
focused_window_id=None,
|
||||||
|
focused_window_title="",
|
||||||
|
)
|
||||||
|
plugin.app.getCompositorStateAdapter.return_value.get_snapshot.return_value = snapshot
|
||||||
|
|
||||||
|
with mock.patch.object(plugin, "_get_active_window") as getX11Window:
|
||||||
|
self.assertEqual(plugin.get_fallback_title("Browser"), "")
|
||||||
|
|
||||||
|
getX11Window.assert_not_called()
|
||||||
|
|
||||||
|
def test_unknown_i3_context_falls_back_to_generic_x11_title(self):
|
||||||
|
plugin = WindowTitleReader()
|
||||||
|
plugin.app = mock.Mock()
|
||||||
|
snapshot = mock.Mock(
|
||||||
|
backend_name="i3-ipc",
|
||||||
|
focused_workspace_empty=None,
|
||||||
|
focused_window_id=None,
|
||||||
|
focused_window_title="",
|
||||||
|
)
|
||||||
|
plugin.app.getCompositorStateAdapter.return_value.get_snapshot.return_value = snapshot
|
||||||
|
plugin._pollSourceId = 1
|
||||||
|
activeWindow = mock.Mock()
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch.object(plugin, "_ensure_x11_display", return_value=True) as ensureX11,
|
||||||
|
mock.patch.object(plugin, "_get_active_window", return_value=activeWindow),
|
||||||
|
mock.patch.object(plugin, "_get_current_title", return_value="Generic X title"),
|
||||||
|
):
|
||||||
|
self.assertEqual(plugin.get_fallback_title("Incomplete"), "Generic X title")
|
||||||
|
|
||||||
|
ensureX11.assert_called_once_with()
|
||||||
|
|
||||||
|
def test_known_i3_xid_with_missing_title_uses_exact_x11_title(self):
|
||||||
|
plugin = WindowTitleReader()
|
||||||
|
plugin.app = mock.Mock()
|
||||||
|
snapshot = mock.Mock(
|
||||||
|
backend_name="i3-ipc",
|
||||||
|
focused_workspace_empty=False,
|
||||||
|
focused_window_id=0x2600003,
|
||||||
|
focused_window_title="",
|
||||||
|
)
|
||||||
|
activeWindow = mock.Mock()
|
||||||
|
plugin.app.getCompositorStateAdapter.return_value.get_snapshot.return_value = snapshot
|
||||||
|
plugin._display = mock.Mock()
|
||||||
|
plugin._display.create_resource_object.return_value = activeWindow
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch.object(plugin, "_ensure_x11_display", return_value=True),
|
||||||
|
mock.patch.object(
|
||||||
|
plugin,
|
||||||
|
"_get_current_title",
|
||||||
|
return_value="Exact X11 title",
|
||||||
|
) as getCurrentTitle,
|
||||||
|
):
|
||||||
|
self.assertEqual(
|
||||||
|
plugin.get_fallback_title("Incomplete"),
|
||||||
|
"Exact X11 title",
|
||||||
|
)
|
||||||
|
|
||||||
|
plugin._display.create_resource_object.assert_called_once_with("window", 0x2600003)
|
||||||
|
getCurrentTitle.assert_called_once_with(activeWindow)
|
||||||
|
|
||||||
def test_present_title_uses_fallback_instead_of_atspi_title(self):
|
def test_present_title_uses_fallback_instead_of_atspi_title(self):
|
||||||
presenter = where_am_i_presenter.WhereAmIPresenter()
|
presenter = where_am_i_presenter.WhereAmIPresenter()
|
||||||
script = mock.Mock()
|
script = mock.Mock()
|
||||||
|
|||||||
@@ -19,6 +19,28 @@ class WineAccessControllerContractTests(unittest.TestCase):
|
|||||||
match.group(0),
|
match.group(0),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_helper_uses_windows_subsystem_without_a_console_window(self):
|
||||||
|
cmakePath = Path(__file__).resolve().parents[1] / "wine-access" / "CMakeLists.txt"
|
||||||
|
source = cmakePath.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertIn(
|
||||||
|
"target_link_options(cthulhu-wine-access PRIVATE -mwindows)",
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_compatibility_window_stays_hidden_zero_sized_and_nonactivating(self):
|
||||||
|
sourcePath = Path(__file__).resolve().parents[1] / "wine-access" / "main.cpp"
|
||||||
|
source = sourcePath.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertIn("WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW", source)
|
||||||
|
self.assertIsNotNone(re.search(
|
||||||
|
r"CreateWindowExW\(WS_EX_NOACTIVATE \| WS_EX_TOOLWINDOW,.*?"
|
||||||
|
r"L\"NVDA\", 0, 0, 0, 0, 0,",
|
||||||
|
source,
|
||||||
|
re.DOTALL,
|
||||||
|
))
|
||||||
|
self.assertIn("ShowWindow(window, SW_HIDE)", source)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -5,6 +5,18 @@ from cthulhu.wine_access_manager import PrefixProcess, WineAccessManager
|
|||||||
|
|
||||||
|
|
||||||
class WineAccessManagerTest(unittest.TestCase):
|
class WineAccessManagerTest(unittest.TestCase):
|
||||||
|
@mock.patch.object(WineAccessManager, "_find_helper", return_value=None)
|
||||||
|
@mock.patch.object(WineAccessManager, "discover_prefixes")
|
||||||
|
def test_poll_without_installed_helper_is_a_no_op(
|
||||||
|
self, discover_prefixes, _find_helper
|
||||||
|
):
|
||||||
|
manager = WineAccessManager(helperPath=None)
|
||||||
|
|
||||||
|
self.assertIsNone(manager.helperPath)
|
||||||
|
self.assertTrue(manager.poll())
|
||||||
|
discover_prefixes.assert_not_called()
|
||||||
|
self.assertEqual(manager.prefixes, {})
|
||||||
|
|
||||||
def test_prefix_prefers_explicit_wineprefix(self):
|
def test_prefix_prefers_explicit_wineprefix(self):
|
||||||
environment = {
|
environment = {
|
||||||
"WINEPREFIX": "/tmp/custom-prefix",
|
"WINEPREFIX": "/tmp/custom-prefix",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ target_compile_features(cthulhu-wine-access PRIVATE cxx_std_20)
|
|||||||
target_include_directories(cthulhu-wine-access PRIVATE "${generated_dir}")
|
target_include_directories(cthulhu-wine-access PRIVATE "${generated_dir}")
|
||||||
target_link_libraries(cthulhu-wine-access PRIVATE
|
target_link_libraries(cthulhu-wine-access PRIVATE
|
||||||
PkgConfig::GIO oleacc ole32 oleaut32 rpcrt4 user32)
|
PkgConfig::GIO oleacc ole32 oleaut32 rpcrt4 user32)
|
||||||
|
target_link_options(cthulhu-wine-access PRIVATE -mwindows)
|
||||||
target_compile_options(cthulhu-wine-access PRIVATE
|
target_compile_options(cthulhu-wine-access PRIVATE
|
||||||
$<$<COMPILE_LANGUAGE:C,CXX>:-Wall -Wextra>)
|
$<$<COMPILE_LANGUAGE:C,CXX>:-Wall -Wextra>)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user