Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
285bf3e59c | ||
|
|
0d8f843814 | ||
|
|
5489847475 | ||
|
|
87c1cbff73 | ||
|
|
4a9f520433 | ||
|
|
b68b481b7c | ||
|
|
ce78dd2e7c | ||
|
|
2f455a6a5e | ||
|
|
57fe74e4d1 | ||
|
|
46b6c1bd56 | ||
|
|
230243cfec | ||
|
|
4381c24a82 | ||
|
|
57b3328ebe |
82
.gitea/workflows/aur-publish.yml
Normal file
82
.gitea/workflows/aur-publish.yml
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
name: Publish to AUR
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
aur:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: archlinux:base-devel
|
||||||
|
steps:
|
||||||
|
- name: Install base tools
|
||||||
|
run: pacman -Syu --noconfirm git openssh pacman-contrib nodejs sudo
|
||||||
|
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Extract version from tag
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
TAG="${GITHUB_REF#refs/tags/v}"
|
||||||
|
echo "version=$TAG" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Publishing pi3-smart-workspace v$TAG to AUR"
|
||||||
|
|
||||||
|
- name: Configure SSH for AUR
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
echo "${{ secrets.AUR_SSH_PRIVATE_KEY }}" > ~/.ssh/aur
|
||||||
|
chmod 600 ~/.ssh/aur
|
||||||
|
ssh-keyscan -H aur.archlinux.org >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
cat > ~/.ssh/config <<'EOF'
|
||||||
|
Host aur.archlinux.org
|
||||||
|
User aur
|
||||||
|
IdentityFile ~/.ssh/aur
|
||||||
|
StrictHostKeyChecking accept-new
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- name: Create unprivileged build user
|
||||||
|
run: |
|
||||||
|
useradd -m builder
|
||||||
|
echo "builder ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers
|
||||||
|
mkdir -p /home/builder/.ssh
|
||||||
|
cp -r ~/.ssh/. /home/builder/.ssh/
|
||||||
|
chown -R builder:builder /home/builder/.ssh
|
||||||
|
chmod 600 /home/builder/.ssh/aur
|
||||||
|
|
||||||
|
- name: Clone existing AUR repo (or init new)
|
||||||
|
run: |
|
||||||
|
git clone ssh://aur@aur.archlinux.org/pi3-smart-workspace.git aur-pkg 2>&1 || true
|
||||||
|
if [ ! -d aur-pkg/.git ]; then
|
||||||
|
mkdir -p aur-pkg
|
||||||
|
cd aur-pkg
|
||||||
|
git init -b master
|
||||||
|
git remote add origin ssh://aur@aur.archlinux.org/pi3-smart-workspace.git
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Render PKGBUILD for this release
|
||||||
|
run: |
|
||||||
|
cp packaging/aur/PKGBUILD aur-pkg/PKGBUILD
|
||||||
|
sed -i "s/^pkgver=.*/pkgver=${{ steps.version.outputs.version }}/" aur-pkg/PKGBUILD
|
||||||
|
sed -i "s/^pkgrel=.*/pkgrel=1/" aur-pkg/PKGBUILD
|
||||||
|
|
||||||
|
- name: Update checksums and regenerate .SRCINFO
|
||||||
|
run: |
|
||||||
|
chown -R builder:builder aur-pkg
|
||||||
|
cd aur-pkg
|
||||||
|
sudo -u builder updpkgsums
|
||||||
|
sudo -u builder makepkg --printsrcinfo > .SRCINFO
|
||||||
|
|
||||||
|
- name: Commit and push to AUR
|
||||||
|
run: |
|
||||||
|
cd aur-pkg
|
||||||
|
git config user.name "Asger Geel Weirsoe"
|
||||||
|
git config user.email "asger@weircon.dk"
|
||||||
|
git add PKGBUILD .SRCINFO
|
||||||
|
if git diff --staged --quiet; then
|
||||||
|
echo "No changes — nothing to push."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
git commit -m "Update to ${{ steps.version.outputs.version }}"
|
||||||
|
git push -u origin master
|
||||||
25
.github/workflows/ci.yml
vendored
Normal file
25
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
pull_request:
|
||||||
|
branches: [master]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.14"
|
||||||
|
|
||||||
|
- name: Install package + dev deps
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
|
||||||
|
- name: Run pytest
|
||||||
|
run: pytest -v
|
||||||
13
.github/workflows/greetings.yml
vendored
Normal file
13
.github/workflows/greetings.yml
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
name: Greetings
|
||||||
|
|
||||||
|
on: [pull_request, issues]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
greeting:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/first-interaction@v1
|
||||||
|
with:
|
||||||
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
issue-message: 'Hey, thanks for contributing to pi3-smart-workspace by submitting an issue! :smile:\nIt is greatly appriciated even if you don''t know or want to submit a PR, this right here is contributing to making this pi3-smart-workspace a better tool, and thus, makes us all, live in a better world!'
|
||||||
|
pr-message: 'You, sir or mam, are awesome! Thanks for the PR! I''ll look into it as soon as I have time for it! Your help, thoughts and actions are greatly appriciated!\nThank you! -@generaldenmark'
|
||||||
40
.github/workflows/python-publish.yml
vendored
40
.github/workflows/python-publish.yml
vendored
@@ -1,6 +1,3 @@
|
|||||||
# This workflows will upload a Python Package using Twine when a release is created
|
|
||||||
# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries
|
|
||||||
|
|
||||||
name: Upload Python Package
|
name: Upload Python Package
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -9,23 +6,24 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v4
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v2
|
- uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: '3.x'
|
python-version: "3.14"
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
- name: Install build tooling
|
||||||
python -m pip install --upgrade pip
|
run: |
|
||||||
pip install setuptools wheel twine
|
python -m pip install --upgrade pip
|
||||||
- name: Build and publish
|
pip install build twine
|
||||||
env:
|
|
||||||
TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
|
- name: Build sdist and wheel
|
||||||
TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
|
run: python -m build
|
||||||
run: |
|
|
||||||
python setup.py sdist bdist_wheel
|
- name: Publish to PyPI
|
||||||
twine upload --repository pypi dist/*
|
env:
|
||||||
|
TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
|
||||||
|
TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
|
||||||
|
run: twine upload --repository pypi dist/*
|
||||||
|
|||||||
194
README.md
Normal file
194
README.md
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
# pi3-smart-workspace
|
||||||
|
|
||||||
|
A small CLI helper for the [i3 window manager](https://i3wm.org/). Bound to
|
||||||
|
`$mod+<n>` keys, it switches (or moves a container to) the **N-th workspace
|
||||||
|
declared for the output your mouse cursor is currently on**, rather than
|
||||||
|
i3's default behaviour of jumping to a globally-numbered workspace.
|
||||||
|
|
||||||
|
This makes multi-monitor i3 setups feel a lot more seamless: pressing
|
||||||
|
`$mod+1` always lands you on the first workspace of whichever screen you're
|
||||||
|
pointing at.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```
|
||||||
|
usage: pi3-smart-workspace [-h] [-d] -i INDEX [-s] [-k]
|
||||||
|
|
||||||
|
Switch (or shift) to the N-th workspace on the output your cursor is on.
|
||||||
|
|
||||||
|
options:
|
||||||
|
-h, --help show this help message and exit
|
||||||
|
-d, --debug Print parsed state and re-raise exceptions.
|
||||||
|
|
||||||
|
Required:
|
||||||
|
-i INDEX, --index INDEX
|
||||||
|
The 1-based index into the workspaces declared for
|
||||||
|
the cursor's output.
|
||||||
|
|
||||||
|
Shift:
|
||||||
|
manipulate the active window
|
||||||
|
|
||||||
|
-s, --shift Move the focused container to the indexed workspace.
|
||||||
|
-k, --keep-with-it With --shift, also follow the container to the new
|
||||||
|
workspace.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Install from PyPI:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pip install pi3-smart-workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
For local development, clone the repo and install in editable mode with the
|
||||||
|
dev extras:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires Python 3.14+ and a running i3 session.
|
||||||
|
|
||||||
|
## i3 configuration
|
||||||
|
|
||||||
|
`pi3-smart-workspace` parses your live i3 config and looks for three
|
||||||
|
specific patterns:
|
||||||
|
|
||||||
|
1. `set $<var> <output-name>` — output aliases
|
||||||
|
2. `set $<var> <digit…>` — workspace-name aliases (quoted or unquoted)
|
||||||
|
3. `workspace $<var> output $<var>` — bindings of workspaces to outputs
|
||||||
|
|
||||||
|
The order in which `workspace … output …` lines appear in your config
|
||||||
|
determines the N-th workspace for that output.
|
||||||
|
|
||||||
|
### Bootstrap with `init` (recommended)
|
||||||
|
|
||||||
|
Instead of writing the config by hand, run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pi3-smart-workspace init
|
||||||
|
```
|
||||||
|
|
||||||
|
This detects every active monitor via i3 and writes:
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.config/i3/pi3-smart-workspace/
|
||||||
|
├── bindings.conf # $mod+N, $mod+Shift+N, $mod+Ctrl+N bindings
|
||||||
|
├── eDP-1/
|
||||||
|
│ └── workspaces.conf # workspaces 1..8 bound to eDP-1
|
||||||
|
├── HDMI-A-0/
|
||||||
|
│ └── workspaces.conf # workspaces 9..16 bound to HDMI-A-0
|
||||||
|
└── DP-1/
|
||||||
|
└── workspaces.conf # workspaces 17..24 bound to DP-1
|
||||||
|
```
|
||||||
|
|
||||||
|
Each output gets a contiguous range of global workspace numbers; the
|
||||||
|
local index (the number after the colon, e.g. `9:1`) tells you "first
|
||||||
|
workspace on this monitor". Then add the include lines `init` prints to
|
||||||
|
your main i3 config and reload (`$mod+Shift+r`):
|
||||||
|
|
||||||
|
```i3config
|
||||||
|
include ~/.config/i3/pi3-smart-workspace/bindings.conf
|
||||||
|
include ~/.config/i3/pi3-smart-workspace/*/workspaces.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
i3's `include` directive needs i3 ≥ 4.20.
|
||||||
|
|
||||||
|
Useful flags:
|
||||||
|
|
||||||
|
| Flag | Default | Effect |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `-n N` / `--count N` | 8 | Workspaces per output |
|
||||||
|
| `--config-dir PATH` | `~/.config/i3/pi3-smart-workspace` | Where to write |
|
||||||
|
| `--dry-run` | off | Print to stdout, don't touch the filesystem |
|
||||||
|
| `--force` | off | Overwrite existing files |
|
||||||
|
|
||||||
|
Re-run `init --force` after plugging or unplugging a monitor to
|
||||||
|
regenerate the per-output folders.
|
||||||
|
|
||||||
|
### Example config (manual)
|
||||||
|
|
||||||
|
```i3config
|
||||||
|
# Displays
|
||||||
|
set $primary eDP
|
||||||
|
set $top HDMI-A-0
|
||||||
|
set $bottom HDMI2
|
||||||
|
|
||||||
|
# Workspaces
|
||||||
|
set $ws1 1:1
|
||||||
|
# ... and so on
|
||||||
|
set $ws{n} {n}:{n}
|
||||||
|
|
||||||
|
set $TopWs1 {n+1}:1
|
||||||
|
# ... and so on
|
||||||
|
set $TopWs{k} {n+1+k}:{k}
|
||||||
|
|
||||||
|
set $BottomWs1 {k+1}:1
|
||||||
|
# ... and so on
|
||||||
|
set $BottomWs{q} {k+1+q}:{q}
|
||||||
|
|
||||||
|
workspace $ws1 output $primary
|
||||||
|
# ... and so on
|
||||||
|
workspace $ws{n} output $primary
|
||||||
|
|
||||||
|
workspace $TopWs1 output $top
|
||||||
|
# ... and so on
|
||||||
|
workspace $TopWs{k} output $top
|
||||||
|
|
||||||
|
workspace $BottomWs1 output $bottom
|
||||||
|
# ... and so on
|
||||||
|
workspace $BottomWs{q} output $bottom
|
||||||
|
|
||||||
|
# Switch to workspace
|
||||||
|
bindsym $mod+1 exec --no-startup-id pi3-smart-workspace -i 1
|
||||||
|
bindsym $mod+2 exec --no-startup-id pi3-smart-workspace -i 2
|
||||||
|
bindsym $mod+3 exec --no-startup-id pi3-smart-workspace -i 3
|
||||||
|
bindsym $mod+4 exec --no-startup-id pi3-smart-workspace -i 4
|
||||||
|
bindsym $mod+5 exec --no-startup-id pi3-smart-workspace -i 5
|
||||||
|
bindsym $mod+6 exec --no-startup-id pi3-smart-workspace -i 6
|
||||||
|
bindsym $mod+7 exec --no-startup-id pi3-smart-workspace -i 7
|
||||||
|
bindsym $mod+8 exec --no-startup-id pi3-smart-workspace -i 8
|
||||||
|
|
||||||
|
# Move focused container to workspace
|
||||||
|
bindsym $mod+Shift+1 exec --no-startup-id pi3-smart-workspace -i 1 -s
|
||||||
|
bindsym $mod+Shift+2 exec --no-startup-id pi3-smart-workspace -i 2 -s
|
||||||
|
bindsym $mod+Shift+3 exec --no-startup-id pi3-smart-workspace -i 3 -s
|
||||||
|
bindsym $mod+Shift+4 exec --no-startup-id pi3-smart-workspace -i 4 -s
|
||||||
|
bindsym $mod+Shift+5 exec --no-startup-id pi3-smart-workspace -i 5 -s
|
||||||
|
bindsym $mod+Shift+6 exec --no-startup-id pi3-smart-workspace -i 6 -s
|
||||||
|
bindsym $mod+Shift+7 exec --no-startup-id pi3-smart-workspace -i 7 -s
|
||||||
|
bindsym $mod+Shift+8 exec --no-startup-id pi3-smart-workspace -i 8 -s
|
||||||
|
|
||||||
|
# Move to workspace with focused container
|
||||||
|
bindsym $mod+Ctrl+1 exec --no-startup-id pi3-smart-workspace -i 1 -sk
|
||||||
|
bindsym $mod+Ctrl+2 exec --no-startup-id pi3-smart-workspace -i 2 -sk
|
||||||
|
bindsym $mod+Ctrl+3 exec --no-startup-id pi3-smart-workspace -i 3 -sk
|
||||||
|
bindsym $mod+Ctrl+4 exec --no-startup-id pi3-smart-workspace -i 4 -sk
|
||||||
|
bindsym $mod+Ctrl+5 exec --no-startup-id pi3-smart-workspace -i 5 -sk
|
||||||
|
bindsym $mod+Ctrl+6 exec --no-startup-id pi3-smart-workspace -i 6 -sk
|
||||||
|
bindsym $mod+Ctrl+7 exec --no-startup-id pi3-smart-workspace -i 7 -sk
|
||||||
|
bindsym $mod+Ctrl+8 exec --no-startup-id pi3-smart-workspace -i 8 -sk
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
pytest # run the unit tests
|
||||||
|
python -m build # build sdist + wheel into dist/
|
||||||
|
twine check dist/* # validate the distributions
|
||||||
|
```
|
||||||
|
|
||||||
|
The unit tests cover the two pure functions (`parse_workspaces_per_output`
|
||||||
|
and `find_output_at_cursor`) and run without an X display, so they work in
|
||||||
|
CI. The i3-dispatch path needs a real multi-monitor session and is only
|
||||||
|
exercised manually.
|
||||||
|
|
||||||
|
## Credits
|
||||||
|
|
||||||
|
Thanks to Michał Wieluński for the inspiration
|
||||||
|
([pi3-switch](https://github.com/landmaj/pi3-switch)) and Tony Crisci for
|
||||||
|
the easy-to-use i3 Python library
|
||||||
|
([i3ipc-python](https://github.com/acrisci/i3ipc-python)).
|
||||||
140
README.rst
140
README.rst
@@ -1,140 +0,0 @@
|
|||||||
About
|
|
||||||
-----
|
|
||||||
|
|
||||||
Simple program that looks through the i3 config and finds the bound workspaces for each output, and then opening that workspace on the output, that the mouse is currently on.
|
|
||||||
|
|
||||||
Allowing for a more seameless interaction with how workspaces are openend.
|
|
||||||
|
|
||||||
Usage
|
|
||||||
-----
|
|
||||||
|
|
||||||
::
|
|
||||||
|
|
||||||
usage: pi3-smart-switch [-h] -i num
|
|
||||||
Openens the [i] number of workspace assigned in the config, on the output the cursor is currently on.
|
|
||||||
|
|
||||||
|
|
||||||
requred arguments:
|
|
||||||
-i, --index the number index of the workspace that should be openend. 1 = first workspace in config etc.
|
|
||||||
|
|
||||||
Current limitations
|
|
||||||
--------------------
|
|
||||||
The way this script is set up, it is sending commands in strings. and thus we cannot keep track of each workspace other than by its name. This is a limmitiaion as there is no way for us to know if the workspace "1" is reffering to the workspace 1 assigned to output DS-1 or output HDMI-2..
|
|
||||||
|
|
||||||
So in order to differentiate between these, you need to name your workspaces new names for each output. See example configuration under #Installation.
|
|
||||||
|
|
||||||
|
|
||||||
Installation
|
|
||||||
------------
|
|
||||||
|
|
||||||
Install using pip (recommended):
|
|
||||||
|
|
||||||
::
|
|
||||||
|
|
||||||
pip install pi3-smart-workspace
|
|
||||||
|
|
||||||
Example config to be inserted into your i3 config.
|
|
||||||
|
|
||||||
::
|
|
||||||
|
|
||||||
# Displays
|
|
||||||
set $primary DP-2
|
|
||||||
set $left HDMI-0
|
|
||||||
set $right HDMI-1
|
|
||||||
|
|
||||||
# Workspaces
|
|
||||||
set $ws1 1:1:Code
|
|
||||||
set $ws2 2:2:Code
|
|
||||||
set $ws3 3:3:Code
|
|
||||||
set $ws4 4:4:Code
|
|
||||||
set $ws5 5:5:Code
|
|
||||||
set $ws6 6:6:Code
|
|
||||||
set $ws7 7:7:Code
|
|
||||||
set $ws8 8:8:Code
|
|
||||||
|
|
||||||
set $LeftWs1 1:1:Browser
|
|
||||||
set $LeftWs2 2:2:Left
|
|
||||||
set $LeftWs3 3:3:Left
|
|
||||||
set $LeftWs4 4:4:Left
|
|
||||||
set $LeftWs5 5:5:Left
|
|
||||||
set $LeftWs6 6:6:Left
|
|
||||||
set $LeftWs7 7:7:Left
|
|
||||||
set $LeftWs8 8:8:Left
|
|
||||||
|
|
||||||
set $RightWs1 1:1:Right
|
|
||||||
set $RightWs2 2:2:Right
|
|
||||||
set $RightWs3 3:3:Right
|
|
||||||
set $RightWs4 4:4:Right
|
|
||||||
set $RightWs5 5:5:Right
|
|
||||||
set $RightWs6 6:6:Right
|
|
||||||
set $RightWs7 7:7:Right
|
|
||||||
set $RightWs8 8:8:Right
|
|
||||||
|
|
||||||
# Workspace assignments
|
|
||||||
workspace $ws1 output $primary
|
|
||||||
workspace $ws2 output $primary
|
|
||||||
workspace $ws3 output $primary
|
|
||||||
workspace $ws4 output $primary
|
|
||||||
workspace $ws5 output $primary
|
|
||||||
workspace $ws6 output $primary
|
|
||||||
workspace $ws7 output $primary
|
|
||||||
workspace $ws8 output $primary
|
|
||||||
|
|
||||||
workspace $LeftWs1 output $left
|
|
||||||
workspace $LeftWs2 output $left
|
|
||||||
workspace $LeftWs3 output $left
|
|
||||||
workspace $LeftWs4 output $left
|
|
||||||
workspace $LeftWs5 output $left
|
|
||||||
workspace $LeftWs6 output $left
|
|
||||||
workspace $LeftWs7 output $left
|
|
||||||
workspace $LeftWs8 output $left
|
|
||||||
|
|
||||||
workspace $RightWs1 output $right
|
|
||||||
workspace $RightWs2 output $right
|
|
||||||
workspace $RightWs3 output $right
|
|
||||||
workspace $RightWs4 output $right
|
|
||||||
workspace $RightWs5 output $right
|
|
||||||
workspace $RightWs6 output $right
|
|
||||||
workspace $RightWs7 output $right
|
|
||||||
workspace $RightWs8 output $right
|
|
||||||
|
|
||||||
# Shift workspace
|
|
||||||
bindsym $mod+1 exec pi3-smart-workspace -i 1
|
|
||||||
bindsym $mod+2 exec pi3-smart-workspace -i 2
|
|
||||||
bindsym $mod+3 exec pi3-smart-workspace -i 3
|
|
||||||
bindsym $mod+4 exec pi3-smart-workspace -i 4
|
|
||||||
bindsym $mod+5 exec pi3-smart-workspace -i 5
|
|
||||||
bindsym $mod+6 exec pi3-smart-workspace -i 6
|
|
||||||
bindsym $mod+7 exec pi3-smart-workspace -i 7
|
|
||||||
bindsym $mod+8 exec pi3-smart-workspace -i 8
|
|
||||||
|
|
||||||
# Move focused container to workspace
|
|
||||||
bindsym $mod+Ctrl+1 exec pi3-smart-workspace -i 1 -s
|
|
||||||
bindsym $mod+Ctrl+2 exec pi3-smart-workspace -i 2 -s
|
|
||||||
bindsym $mod+Ctrl+3 exec pi3-smart-workspace -i 3 -s
|
|
||||||
bindsym $mod+Ctrl+4 exec pi3-smart-workspace -i 4 -s
|
|
||||||
bindsym $mod+Ctrl+5 exec pi3-smart-workspace -i 5 -s
|
|
||||||
bindsym $mod+Ctrl+6 exec pi3-smart-workspace -i 6 -s
|
|
||||||
bindsym $mod+Ctrl+7 exec pi3-smart-workspace -i 7 -s
|
|
||||||
bindsym $mod+Ctrl+8 exec pi3-smart-workspace -i 8 -s
|
|
||||||
|
|
||||||
# Move to workspace with focused container
|
|
||||||
bindsym $mod+Shift+1 exec pi3-smart-workspace -i 1 -sk
|
|
||||||
bindsym $mod+Shift+2 exec pi3-smart-workspace -i 2 -sk
|
|
||||||
bindsym $mod+Shift+3 exec pi3-smart-workspace -i 3 -sk
|
|
||||||
bindsym $mod+Shift+4 exec pi3-smart-workspace -i 4 -sk
|
|
||||||
bindsym $mod+Shift+5 exec pi3-smart-workspace -i 5 -sk
|
|
||||||
bindsym $mod+Shift+6 exec pi3-smart-workspace -i 6 -sk
|
|
||||||
bindsym $mod+Shift+7 exec pi3-smart-workspace -i 7 -sk
|
|
||||||
bindsym $mod+Shift+8 exec pi3-smart-workspace -i 8 -sk
|
|
||||||
|
|
||||||
|
|
||||||
Credits
|
|
||||||
-------
|
|
||||||
|
|
||||||
Thanks to Michał Wieluński for an inspiration (`pi3-switch`_) and
|
|
||||||
Tony Crisci for an easy-to-use i3 python library (`i3ipc-python`_).
|
|
||||||
|
|
||||||
.. _pipsi: https://github.com/mitsuhiko/pipsi
|
|
||||||
.. _pi3-switch: https://github.com/landmaj/pi3-switch
|
|
||||||
.. _i3ipc-python: https://github.com/acrisci/i3ipc-python
|
|
||||||
32
build.py
32
build.py
@@ -1,32 +0,0 @@
|
|||||||
import argparse
|
|
||||||
import subprocess
|
|
||||||
import pathlib
|
|
||||||
import os
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser("Build script for pypi and pypi test")
|
|
||||||
|
|
||||||
group = parser.add_mutually_exclusive_group(required=True)
|
|
||||||
|
|
||||||
group.add_argument('--test', action='store_true', help='Build to test.pypi.org')
|
|
||||||
group.add_argument('--pypi', action='store_true', help='Build to pypi.org')
|
|
||||||
group.add_argument('--check', action='store_true', help='Displays the twine check for dist')
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
for path in pathlib.Path('dist').iterdir():
|
|
||||||
os.remove(path)
|
|
||||||
|
|
||||||
subprocess.call(['python3', 'setup.py', 'sdist', 'bdist_wheel'], stdout=subprocess.PIPE)
|
|
||||||
|
|
||||||
if args.test:
|
|
||||||
subprocess.call(['twine', 'upload', '--config-file', '.pypirc', '--repository', 'testpypi', 'dist/*'])
|
|
||||||
|
|
||||||
elif args.pypi:
|
|
||||||
subprocess.call(['twine', 'upload', '--config-file', '.pypirc', '--repository', 'pypi', 'dist/*'])
|
|
||||||
|
|
||||||
else:
|
|
||||||
subprocess.call(['twine', 'check', 'dist/*'])
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
23
packaging/aur/PKGBUILD
Normal file
23
packaging/aur/PKGBUILD
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Maintainer: Asger Geel Weirsoe <asger@weircon.dk>
|
||||||
|
pkgname=pi3-smart-workspace
|
||||||
|
pkgver=0.0.0
|
||||||
|
pkgrel=1
|
||||||
|
pkgdesc="Switch i3 workspaces based on which output your mouse cursor is on"
|
||||||
|
arch=('any')
|
||||||
|
url="https://gitea.weircon.dk/agw/pi3-smart-workspace"
|
||||||
|
license=('Apache-2.0')
|
||||||
|
depends=('python' 'i3-wm' 'python-i3ipc' 'python-pynput')
|
||||||
|
makedepends=('python-build' 'python-installer' 'python-hatchling' 'python-wheel')
|
||||||
|
source=("$pkgname-$pkgver.tar.gz::$url/archive/v$pkgver.tar.gz")
|
||||||
|
sha256sums=('SKIP')
|
||||||
|
|
||||||
|
build() {
|
||||||
|
cd "$srcdir/$pkgname"
|
||||||
|
python -m build --wheel --no-isolation
|
||||||
|
}
|
||||||
|
|
||||||
|
package() {
|
||||||
|
cd "$srcdir/$pkgname"
|
||||||
|
python -m installer --destdir="$pkgdir" dist/*.whl
|
||||||
|
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||||
|
}
|
||||||
@@ -1,114 +1,640 @@
|
|||||||
from i3ipc import Connection
|
"""Per-output workspace switcher for the i3 window manager.
|
||||||
import sys
|
|
||||||
import pynput
|
This module powers the ``pi3-smart-workspace`` CLI. When invoked with
|
||||||
import re
|
``-i N``, it switches to the N-th workspace declared in the user's i3
|
||||||
|
config *for the output the mouse cursor is currently on* — rather than
|
||||||
|
the N-th globally-numbered workspace, which is i3's default.
|
||||||
|
|
||||||
|
The flow on each invocation:
|
||||||
|
|
||||||
|
1. Connect to i3 over the IPC socket and discover which outputs are
|
||||||
|
active right now (no shelling out to ``xrandr``).
|
||||||
|
2. Read the live i3 config via ``i3.get_config()`` and parse it with
|
||||||
|
:func:`parse_workspaces_per_output` into ``{output: [workspace, ...]}``,
|
||||||
|
in the order workspaces are declared in the config.
|
||||||
|
3. Read the current cursor position and use
|
||||||
|
:func:`find_output_at_cursor` to decide which output the cursor is on.
|
||||||
|
4. Issue ``move container to workspace …`` and/or ``workspace …`` via
|
||||||
|
``i3.command(...)``.
|
||||||
|
|
||||||
|
The two parsing/geometry helpers (:func:`parse_workspaces_per_output` and
|
||||||
|
:func:`find_output_at_cursor`) are pure functions and live here to be
|
||||||
|
unit-tested without an X display or a running i3 session. Everything
|
||||||
|
that touches the outside world is confined to :func:`run`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
import pprint
|
import pprint
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
import i3ipc
|
||||||
|
|
||||||
|
|
||||||
class WorkSpacer:
|
DEFAULT_INIT_DIR = pathlib.Path("~/.config/i3/pi3-smart-workspace")
|
||||||
|
DEFAULT_WORKSPACES_PER_OUTPUT = 8
|
||||||
def __init__(self, args):
|
|
||||||
self.i3 = None
|
|
||||||
self.args = args
|
|
||||||
self.workspaces_on_outputs = {}
|
|
||||||
self.workspaces = None
|
|
||||||
self.outputs = None
|
|
||||||
self.config = None
|
|
||||||
self.mouse = pynput.mouse.Controller()
|
|
||||||
self.mouse_position = None
|
|
||||||
self.current_output_name = None
|
|
||||||
|
|
||||||
def _connect(self):
|
|
||||||
try:
|
|
||||||
self.i3 = Connection()
|
|
||||||
self.config = self.i3.get_config().__dict__['config']
|
|
||||||
config_outputs = {}
|
|
||||||
for matchNo, match in enumerate(
|
|
||||||
re.finditer(r'set (\$[a-zA-Z]+) ((HDMI|DP|VGA|eDP)(-|)\d)', self.config, re.MULTILINE), start=1
|
|
||||||
):
|
|
||||||
config_outputs[match.group(1)] = match.group(2)
|
|
||||||
config_workspace_names = {}
|
|
||||||
for matchNum, match in enumerate(
|
|
||||||
re.finditer(r'set (\$.*) (\d.*)', self.config, re.MULTILINE)
|
|
||||||
):
|
|
||||||
config_workspace_names[match.group(1)] = match.group(2)
|
|
||||||
for matchNum, match in enumerate(
|
|
||||||
re.finditer(r'workspace (\$.*) output (\$.*)', self.config, re.MULTILINE)
|
|
||||||
):
|
|
||||||
if not self.workspaces_on_outputs.keys().__contains__(config_outputs[match.group(2)]):
|
|
||||||
self.workspaces_on_outputs[config_outputs[match.group(2)]] = []
|
|
||||||
self.workspaces_on_outputs[config_outputs[match.group(2)]].append(config_workspace_names[match.group(1)])
|
|
||||||
self.print_if_debug("All workspaces with outputs")
|
|
||||||
self.print_if_debug(self.workspaces_on_outputs)
|
|
||||||
|
|
||||||
except Exception as exc:
|
|
||||||
self.print_if_debug(exc)
|
|
||||||
sys.exit(1)
|
|
||||||
self.workspaces = [workspaces for workspaces in self.i3.get_workspaces()]
|
|
||||||
outputs = self.i3.get_outputs()
|
|
||||||
self.outputs = [output for output in outputs if output.__dict__["active"] is True]
|
|
||||||
|
|
||||||
def run(self):
|
|
||||||
self._connect()
|
|
||||||
self.mouse_position = self.mouse.position
|
|
||||||
self.current_output_name = self._get_workspace_from_courser_position()
|
|
||||||
|
|
||||||
if self.args.shift:
|
|
||||||
self.i3.command(f'move container to workspace {self.workspaces_on_outputs[self.current_output_name][self.args.index - 1]}')
|
|
||||||
if not self.args.keep_with_it:
|
|
||||||
return
|
|
||||||
self.i3.command(f'workspace {self.workspaces_on_outputs[self.current_output_name][self.args.index - 1]}')
|
|
||||||
|
|
||||||
def _get_workspace_from_courser_position(self):
|
|
||||||
for output in self.outputs:
|
|
||||||
width = output.__dict__["rect"].__dict__["width"]
|
|
||||||
height = output.__dict__["rect"].__dict__["height"]
|
|
||||||
x_offset = output.__dict__["rect"].__dict__["x"]
|
|
||||||
y_offset = output.__dict__["rect"].__dict__["y"]
|
|
||||||
|
|
||||||
if x_offset == 0 and y_offset == 0:
|
|
||||||
if x_offset <= self.mouse_position[0] <= x_offset + width and y_offset <= self.mouse_position[1] <= y_offset + height:
|
|
||||||
return output.__dict__["name"]
|
|
||||||
elif x_offset == 0:
|
|
||||||
if x_offset <= self.mouse_position[0] <= x_offset + width and y_offset < self.mouse_position[1] <= y_offset + height:
|
|
||||||
return output.__dict__["name"]
|
|
||||||
elif y_offset == 0:
|
|
||||||
if x_offset < self.mouse_position[0] <= x_offset + width and y_offset <= self.mouse_position[1] <= y_offset + height:
|
|
||||||
return output.__dict__["name"]
|
|
||||||
else:
|
|
||||||
if x_offset < self.mouse_position[0] <= x_offset + width and y_offset < self.mouse_position[1] <= y_offset + height:
|
|
||||||
return output.__dict__["name"]
|
|
||||||
|
|
||||||
def _get_workspaces_for_output(self, output):
|
|
||||||
return [workspace for workspace in self.workspaces if workspace.__dict__['output'] == output]
|
|
||||||
|
|
||||||
def print_if_debug(self, to_print):
|
|
||||||
if self.args.debug:
|
|
||||||
pprint.pprint(to_print)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
@dataclass(frozen=True)
|
||||||
parser = argparse.ArgumentParser(
|
class Rect:
|
||||||
description="Dynamic changes the workspace, based on what output your cursoer is on."
|
"""Axis-aligned rectangle in screen coordinates.
|
||||||
|
|
||||||
|
Mirrors the relevant fields of an ``i3ipc`` output rect so that
|
||||||
|
:func:`find_output_at_cursor` can stay free of any ``i3ipc``
|
||||||
|
dependency and be exercised by plain unit tests.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
x: X coordinate of the top-left corner, in pixels.
|
||||||
|
y: Y coordinate of the top-left corner, in pixels.
|
||||||
|
width: Width of the rectangle, in pixels.
|
||||||
|
height: Height of the rectangle, in pixels.
|
||||||
|
"""
|
||||||
|
|
||||||
|
x: int
|
||||||
|
y: int
|
||||||
|
width: int
|
||||||
|
height: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Args:
|
||||||
|
"""Parsed CLI arguments, as a typed alternative to ``argparse.Namespace``.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
index: 1-based index into the per-output workspace list.
|
||||||
|
shift: If true, move the focused container to the target workspace.
|
||||||
|
keep_with_it: If true *and* ``shift`` is true, also follow the
|
||||||
|
container to the new workspace.
|
||||||
|
debug: If true, print parsed intermediate state to stderr and
|
||||||
|
re-raise exceptions instead of producing a friendly message.
|
||||||
|
"""
|
||||||
|
|
||||||
|
index: int
|
||||||
|
shift: bool
|
||||||
|
keep_with_it: bool
|
||||||
|
debug: bool
|
||||||
|
|
||||||
|
|
||||||
|
_INCLUDE_RE = re.compile(r"^\s*include\s+(\S.*?)\s*$", re.MULTILINE)
|
||||||
|
|
||||||
|
|
||||||
|
def expand_i3_includes(
|
||||||
|
config_text: str,
|
||||||
|
base_dir: pathlib.Path,
|
||||||
|
_visited: set[pathlib.Path] | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Inline all i3 ``include`` directives in ``config_text``, recursively.
|
||||||
|
|
||||||
|
The i3 IPC ``get_config`` reply returns the raw contents of the loaded
|
||||||
|
config file, *not* the post-include-expanded view that i3 itself acts
|
||||||
|
on. Without this expansion, any workspace/output bindings stashed in
|
||||||
|
included files (which is exactly what ``pi3-smart-workspace init``
|
||||||
|
generates) would be invisible to :func:`parse_workspaces_per_output`.
|
||||||
|
|
||||||
|
The expansion mirrors i3's ``wordexp(3)`` semantics for the common
|
||||||
|
case: leading ``~`` is expanded to ``$HOME``, relative paths are
|
||||||
|
resolved against the directory of the file containing the include,
|
||||||
|
and shell-style globs (``*``, ``?``, ``[…]``) expand alphabetically.
|
||||||
|
Quoted paths and environment variables aren't handled.
|
||||||
|
|
||||||
|
Files that fail to read (missing, permission denied) are silently
|
||||||
|
skipped — i3 itself tolerates that. Include cycles are broken by
|
||||||
|
tracking already-visited resolved paths.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config_text: Raw i3 config text to expand.
|
||||||
|
base_dir: Directory used to resolve relative include paths.
|
||||||
|
_visited: Internal recursion guard. Do not pass.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``config_text`` with every ``include`` line replaced by the
|
||||||
|
contents of the matched files (themselves recursively expanded).
|
||||||
|
Non-include text is preserved verbatim.
|
||||||
|
"""
|
||||||
|
if _visited is None:
|
||||||
|
_visited = set()
|
||||||
|
|
||||||
|
out: list[str] = []
|
||||||
|
last = 0
|
||||||
|
for match in _INCLUDE_RE.finditer(config_text):
|
||||||
|
out.append(config_text[last : match.start()])
|
||||||
|
|
||||||
|
raw_path = os.path.expanduser(match.group(1))
|
||||||
|
if not os.path.isabs(raw_path):
|
||||||
|
raw_path = str(base_dir / raw_path)
|
||||||
|
|
||||||
|
for matched in sorted(glob.glob(raw_path)):
|
||||||
|
path = pathlib.Path(matched)
|
||||||
|
real = path.resolve()
|
||||||
|
if real in _visited:
|
||||||
|
continue
|
||||||
|
_visited.add(real)
|
||||||
|
try:
|
||||||
|
sub_text = path.read_text()
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
out.append(expand_i3_includes(sub_text, path.parent, _visited))
|
||||||
|
|
||||||
|
last = match.end()
|
||||||
|
out.append(config_text[last:])
|
||||||
|
return "".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_workspaces_per_output(
|
||||||
|
config_text: str, active_outputs: Iterable[str]
|
||||||
|
) -> dict[str, list[str]]:
|
||||||
|
"""Extract per-output workspace lists from an i3 config.
|
||||||
|
|
||||||
|
The function expects the user's i3 config to follow the three-part
|
||||||
|
convention documented in ``README.md``::
|
||||||
|
|
||||||
|
set $myOutput HDMI-A-0 # output alias
|
||||||
|
set $ws1 1:web # workspace-name alias
|
||||||
|
workspace $ws1 output $myOutput # binding
|
||||||
|
|
||||||
|
Three regexes are applied to ``config_text``:
|
||||||
|
|
||||||
|
1. ``set $<var> <output-name>`` — only matches output names found in
|
||||||
|
``active_outputs`` (the regex is anchored and the alternation is
|
||||||
|
built from the escaped active names, so unplugged or unknown
|
||||||
|
outputs are silently ignored).
|
||||||
|
2. ``set $<var> <digit…>`` — workspace-name aliases. The value must
|
||||||
|
start with a digit, which avoids accidentally matching unrelated
|
||||||
|
``set`` lines such as ``set $mod Mod4``.
|
||||||
|
3. ``workspace $<var> output $<var>`` — bindings that join the two
|
||||||
|
above. Bindings whose output variable isn't in the active map are
|
||||||
|
skipped.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config_text: The raw i3 config text, as returned by
|
||||||
|
``i3.get_config().config``.
|
||||||
|
active_outputs: Names of outputs that are currently active
|
||||||
|
(from ``i3.get_outputs()`` with ``o.active == True``).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A dict mapping each active output name to the list of workspace
|
||||||
|
names declared for it, in the order their ``workspace … output …``
|
||||||
|
lines appear in the config. Outputs without any bindings are
|
||||||
|
omitted; ``active_outputs`` being empty yields ``{}``.
|
||||||
|
"""
|
||||||
|
active = set(active_outputs)
|
||||||
|
if not active:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# Build the output-name alternation from the *escaped* active names so
|
||||||
|
# that regex metacharacters in output names (e.g. ``DP-1``, ``HDMI-A-0``)
|
||||||
|
# don't trip up the match.
|
||||||
|
output_pattern = "|".join(re.escape(name) for name in active)
|
||||||
|
output_alias_re = re.compile(
|
||||||
|
rf"^\s*set\s+(\$\w+)\s+({output_pattern})\s*$", re.MULTILINE
|
||||||
|
)
|
||||||
|
# Workspace values can be unquoted (``1:1``) or quoted (``"1"``,
|
||||||
|
# ``"1: web"``). Both forms must start with a digit, which keeps us
|
||||||
|
# from accidentally matching unrelated ``set`` lines like
|
||||||
|
# ``set $mod Mod4`` or ``set $term i3-sensible-terminal``.
|
||||||
|
workspace_alias_re = re.compile(
|
||||||
|
r'^\s*set\s+(\$\w+)\s+(?:"(\d[^"]*)"|(\d\S*))\s*$', re.MULTILINE
|
||||||
|
)
|
||||||
|
binding_re = re.compile(
|
||||||
|
r"^\s*workspace\s+(\$\w+)\s+output\s+(\$\w+)\s*$", re.MULTILINE
|
||||||
)
|
)
|
||||||
parser.add_argument('-d', '--debug', action='store_true',
|
|
||||||
help='Turn on debug mode.')
|
|
||||||
|
|
||||||
required_group = parser.add_argument_group('Required', '')
|
output_aliases = {m.group(1): m.group(2) for m in output_alias_re.finditer(config_text)}
|
||||||
required_group.add_argument("-i", "--index", type=int, required=True,
|
# Quoted form lives in group 2 (without surrounding quotes), unquoted in
|
||||||
help="the number index of the workspace that should be openend. 1 = first workspace in config etc.")
|
# group 3. Whichever branch matched, the other group is ``None``.
|
||||||
|
workspace_aliases = {
|
||||||
|
m.group(1): m.group(2) if m.group(2) is not None else m.group(3)
|
||||||
|
for m in workspace_alias_re.finditer(config_text)
|
||||||
|
}
|
||||||
|
|
||||||
shift_group = parser.add_argument_group('Shift', 'manipulate the active window')
|
result: dict[str, list[str]] = {}
|
||||||
shift_group.add_argument("-s", "--shift", action='store_true',
|
for match in binding_re.finditer(config_text):
|
||||||
help="if present, moves the current active window to target workspace")
|
ws_var, out_var = match.group(1), match.group(2)
|
||||||
shift_group.add_argument('-k', '--keep-with-it', action='store_true',
|
# Either side of the binding may reference a variable we didn't
|
||||||
help='if present, moves with the ')
|
# match (e.g. an unplugged output) — drop the binding silently.
|
||||||
pprint.pprint(parser.parse_args().__dict__)
|
if out_var not in output_aliases or ws_var not in workspace_aliases:
|
||||||
WorkSpacer(parser.parse_args()).run()
|
continue
|
||||||
|
output = output_aliases[out_var]
|
||||||
|
result.setdefault(output, []).append(workspace_aliases[ws_var])
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
def find_output_at_cursor(
|
||||||
|
outputs: Iterable[tuple[str, Rect]], cursor: tuple[int, int]
|
||||||
|
) -> str | None:
|
||||||
|
"""Find which output's rect contains a given cursor position.
|
||||||
|
|
||||||
|
Adjacent outputs in a multi-monitor setup share an edge, so a cursor
|
||||||
|
that lands exactly on a shared edge would otherwise belong to two
|
||||||
|
outputs at once. The rule applied here is: the lower bound on each
|
||||||
|
axis is *inclusive only at coordinate 0* (the screen origin) and
|
||||||
|
*exclusive* otherwise. The effect is that a cursor on a shared edge
|
||||||
|
is attributed to the top/left output, never to both.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
outputs: Iterable of ``(name, rect)`` pairs for the active
|
||||||
|
outputs.
|
||||||
|
cursor: ``(x, y)`` cursor position in screen coordinates.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The name of the first output whose rect contains the cursor, or
|
||||||
|
``None`` if no output does (e.g. the cursor is on a region not
|
||||||
|
covered by any monitor).
|
||||||
|
"""
|
||||||
|
x, y = cursor
|
||||||
|
for name, r in outputs:
|
||||||
|
x_in = (r.x <= x if r.x == 0 else r.x < x) and x <= r.x + r.width
|
||||||
|
y_in = (r.y <= y if r.y == 0 else r.y < y) and y <= r.y + r.height
|
||||||
|
if x_in and y_in:
|
||||||
|
return name
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _active_outputs(i3: i3ipc.Connection) -> list[tuple[str, Rect]]:
|
||||||
|
"""Return ``(name, rect)`` for every currently active i3 output.
|
||||||
|
|
||||||
|
Inactive outputs (disconnected monitors that i3 still remembers) are
|
||||||
|
filtered out.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
i3: A connected ``i3ipc.Connection``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A list of ``(output_name, Rect)`` tuples in the order i3 returns
|
||||||
|
them — typically primary-first, but this is not guaranteed.
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
(o.name, Rect(o.rect.x, o.rect.y, o.rect.width, o.rect.height))
|
||||||
|
for o in i3.get_outputs()
|
||||||
|
if o.active
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def run(args: Args) -> None:
|
||||||
|
"""Execute one workspace switch / shift based on ``args``.
|
||||||
|
|
||||||
|
This is the side-effectful counterpart to the pure helpers above:
|
||||||
|
it talks to i3 over IPC, asks the X server for the cursor position
|
||||||
|
via pynput, and issues the resulting i3 commands.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
args: Parsed CLI arguments.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If no active output contains the cursor, or if
|
||||||
|
``args.index`` is outside the range of workspaces declared
|
||||||
|
for the cursor's output.
|
||||||
|
"""
|
||||||
|
i3 = i3ipc.Connection()
|
||||||
|
|
||||||
|
outputs = _active_outputs(i3)
|
||||||
|
if args.debug:
|
||||||
|
pprint.pprint({"active_outputs": outputs})
|
||||||
|
|
||||||
|
config_text = i3.get_config().config
|
||||||
|
# i3.get_config() returns the *raw* file contents — any `include …`
|
||||||
|
# directives must be expanded by us before the parser sees them, or
|
||||||
|
# workspace bindings stashed in included files (the layout `init`
|
||||||
|
# generates) will be invisible.
|
||||||
|
loaded_path = pathlib.Path(i3.get_version().loaded_config_file_name or "")
|
||||||
|
config_base = loaded_path.parent if loaded_path.name else pathlib.Path.home() / ".config/i3"
|
||||||
|
config_text = expand_i3_includes(config_text, config_base)
|
||||||
|
workspaces_per_output = parse_workspaces_per_output(
|
||||||
|
config_text, (name for name, _ in outputs)
|
||||||
|
)
|
||||||
|
if args.debug:
|
||||||
|
pprint.pprint({"workspaces_per_output": workspaces_per_output})
|
||||||
|
|
||||||
|
# pynput touches the X display on import, so keep it lazy: importing
|
||||||
|
# ``pi3.smart_workspace`` itself must work without ``$DISPLAY`` (CI,
|
||||||
|
# unit tests, ``pip install`` post-install hooks, etc.).
|
||||||
|
from pynput.mouse import Controller as MouseController
|
||||||
|
|
||||||
|
cursor = MouseController().position
|
||||||
|
output_name = find_output_at_cursor(outputs, (int(cursor[0]), int(cursor[1])))
|
||||||
|
if output_name is None:
|
||||||
|
raise RuntimeError(f"no active output contains cursor position {cursor}")
|
||||||
|
|
||||||
|
workspaces = workspaces_per_output.get(output_name, [])
|
||||||
|
if not 1 <= args.index <= len(workspaces):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"index {args.index} out of range; output {output_name!r} has "
|
||||||
|
f"{len(workspaces)} workspaces declared"
|
||||||
|
)
|
||||||
|
|
||||||
|
target = workspaces[args.index - 1]
|
||||||
|
if args.shift:
|
||||||
|
i3.command(f"move container to workspace {target}")
|
||||||
|
if not args.keep_with_it:
|
||||||
|
return
|
||||||
|
i3.command(f"workspace {target}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# init subcommand
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
_AUTOGEN_HEADER = (
|
||||||
|
"# Auto-generated by `pi3-smart-workspace init`.\n"
|
||||||
|
"# Re-run the command to regenerate after plugging/unplugging monitors.\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_var_suffix(output_name: str) -> str:
|
||||||
|
"""Turn an i3 output name into a valid i3 variable-name suffix.
|
||||||
|
|
||||||
|
i3 variables must be alphanumeric/underscore (matching ``\\$\\w+``),
|
||||||
|
but output names commonly contain hyphens (``eDP-1``, ``HDMI-A-0``).
|
||||||
|
All non-alphanumeric characters become underscores.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
output_name: The output name as reported by i3 (e.g. ``"HDMI-A-0"``).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A string usable as the tail of an i3 ``$var`` (e.g. ``"HDMI_A_0"``).
|
||||||
|
"""
|
||||||
|
return re.sub(r"[^a-zA-Z0-9]", "_", output_name)
|
||||||
|
|
||||||
|
|
||||||
|
def render_workspaces_for_output(
|
||||||
|
output_name: str, global_start: int, count: int
|
||||||
|
) -> str:
|
||||||
|
"""Render the ``workspaces.conf`` content for a single output.
|
||||||
|
|
||||||
|
The output gets a contiguous block of global workspace numbers
|
||||||
|
``[global_start, global_start + count)``. The label after the colon
|
||||||
|
is the *local* index (1..count) so the i3bar shows e.g. ``9:1`` for
|
||||||
|
the first workspace of a secondary monitor — meaning "global #9,
|
||||||
|
locally the 1st on this output".
|
||||||
|
|
||||||
|
Args:
|
||||||
|
output_name: Active output name from ``i3.get_outputs()``.
|
||||||
|
global_start: First global workspace number to allocate. Must be
|
||||||
|
>= 1.
|
||||||
|
count: How many workspaces to declare for this output. Must be
|
||||||
|
>= 1.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The full file contents, including a leading comment header and a
|
||||||
|
trailing newline.
|
||||||
|
"""
|
||||||
|
if global_start < 1:
|
||||||
|
raise ValueError("global_start must be >= 1")
|
||||||
|
if count < 1:
|
||||||
|
raise ValueError("count must be >= 1")
|
||||||
|
|
||||||
|
var = sanitize_var_suffix(output_name)
|
||||||
|
out_var = f"$out_{var}"
|
||||||
|
ws_vars = [f"$ws_{var}_{local}" for local in range(1, count + 1)]
|
||||||
|
|
||||||
|
lines: list[str] = [
|
||||||
|
_AUTOGEN_HEADER,
|
||||||
|
f"# Output: {output_name}\n",
|
||||||
|
f"set {out_var} {output_name}\n",
|
||||||
|
"\n",
|
||||||
|
]
|
||||||
|
for local, ws_var in enumerate(ws_vars, start=1):
|
||||||
|
global_num = global_start + local - 1
|
||||||
|
lines.append(f"set {ws_var} {global_num}:{local}\n")
|
||||||
|
lines.append("\n")
|
||||||
|
for ws_var in ws_vars:
|
||||||
|
lines.append(f"workspace {ws_var} output {out_var}\n")
|
||||||
|
return "".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def render_bindings(count: int, mod: str = "$mod") -> str:
|
||||||
|
"""Render the shared ``bindings.conf`` content.
|
||||||
|
|
||||||
|
Generates three blocks of ``count`` keybindings each:
|
||||||
|
|
||||||
|
* ``mod+N`` → switch to the N-th workspace on the cursor's output
|
||||||
|
* ``mod+Shift+N`` → move focused container to that workspace
|
||||||
|
* ``mod+Ctrl+N`` → move container *and* follow it
|
||||||
|
|
||||||
|
The ``mod`` variable must already be defined elsewhere in the user's
|
||||||
|
i3 config (i3's stock template defines ``set $mod Mod4``).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
count: Number of bindings per block (typically 8, matching the
|
||||||
|
number of workspaces per output).
|
||||||
|
mod: The mod-key i3 variable to bind against. Default ``"$mod"``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The full file contents, including a leading comment header and a
|
||||||
|
trailing newline.
|
||||||
|
"""
|
||||||
|
if count < 1:
|
||||||
|
raise ValueError("count must be >= 1")
|
||||||
|
|
||||||
|
def block(suffix: str, flags: str, comment: str) -> list[str]:
|
||||||
|
rows = [f"# {comment}\n"]
|
||||||
|
for n in range(1, count + 1):
|
||||||
|
rows.append(
|
||||||
|
f"bindsym {mod}+{suffix}{n} exec --no-startup-id "
|
||||||
|
f"pi3-smart-workspace -i {n}{flags}\n"
|
||||||
|
)
|
||||||
|
rows.append("\n")
|
||||||
|
return rows
|
||||||
|
|
||||||
|
out: list[str] = [_AUTOGEN_HEADER, "\n"]
|
||||||
|
out.extend(block("", "", "Switch to workspace"))
|
||||||
|
out.extend(block("Shift+", " -s", "Move focused container to workspace"))
|
||||||
|
out.extend(block("Ctrl+", " -s -k", "Move container to workspace and follow it"))
|
||||||
|
return "".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class InitArgs:
|
||||||
|
"""Parsed CLI arguments for the ``init`` subcommand."""
|
||||||
|
|
||||||
|
count: int
|
||||||
|
config_dir: pathlib.Path
|
||||||
|
dry_run: bool
|
||||||
|
force: bool
|
||||||
|
debug: bool
|
||||||
|
|
||||||
|
|
||||||
|
def _planned_files(
|
||||||
|
config_dir: pathlib.Path,
|
||||||
|
outputs: list[tuple[str, Rect]],
|
||||||
|
count: int,
|
||||||
|
) -> list[tuple[pathlib.Path, str]]:
|
||||||
|
"""Compute the (path, content) pairs that ``init`` would write.
|
||||||
|
|
||||||
|
Pure function: no filesystem or i3 access. Each output gets a folder
|
||||||
|
``<config_dir>/<output_name>/workspaces.conf``; the shared keybindings
|
||||||
|
go in ``<config_dir>/bindings.conf``. Output order is preserved from
|
||||||
|
``outputs`` so global workspace numbers are deterministic.
|
||||||
|
"""
|
||||||
|
files: list[tuple[pathlib.Path, str]] = [
|
||||||
|
(config_dir / "bindings.conf", render_bindings(count)),
|
||||||
|
]
|
||||||
|
for index, (output_name, _) in enumerate(outputs):
|
||||||
|
global_start = index * count + 1
|
||||||
|
files.append(
|
||||||
|
(
|
||||||
|
config_dir / output_name / "workspaces.conf",
|
||||||
|
render_workspaces_for_output(output_name, global_start, count),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
def run_init(args: InitArgs) -> None:
|
||||||
|
"""Generate per-output i3 workspace configs for the currently-active outputs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
args: Parsed init-subcommand arguments.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If no active outputs are detected, or if files
|
||||||
|
already exist and ``--force`` was not passed.
|
||||||
|
"""
|
||||||
|
i3 = i3ipc.Connection()
|
||||||
|
outputs = _active_outputs(i3)
|
||||||
|
if not outputs:
|
||||||
|
raise RuntimeError("no active outputs detected by i3")
|
||||||
|
|
||||||
|
config_dir = args.config_dir.expanduser()
|
||||||
|
files = _planned_files(config_dir, outputs, args.count)
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
for path, content in files:
|
||||||
|
print(f"--- {path} ---")
|
||||||
|
print(content, end="")
|
||||||
|
return
|
||||||
|
|
||||||
|
existing = [path for path, _ in files if path.exists()]
|
||||||
|
if existing and not args.force:
|
||||||
|
listing = "\n ".join(str(p) for p in existing)
|
||||||
|
raise RuntimeError(
|
||||||
|
"refusing to overwrite existing files (pass --force to replace):\n "
|
||||||
|
+ listing
|
||||||
|
)
|
||||||
|
|
||||||
|
for path, content in files:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(content)
|
||||||
|
print(f"wrote {path}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("Add the following to your i3 config (typically ~/.config/i3/config):")
|
||||||
|
print(f" include {config_dir}/bindings.conf")
|
||||||
|
print(f" include {config_dir}/*/workspaces.conf")
|
||||||
|
print()
|
||||||
|
print("Then reload i3 with $mod+Shift+r.")
|
||||||
|
|
||||||
|
|
||||||
|
def _init_main(argv: list[str]) -> None:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="pi3-smart-workspace init",
|
||||||
|
description="Generate per-output i3 workspace configs and keybindings.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-n", "--count", type=int, default=DEFAULT_WORKSPACES_PER_OUTPUT,
|
||||||
|
help=f"Workspaces per output (default: {DEFAULT_WORKSPACES_PER_OUTPUT}).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--config-dir", type=pathlib.Path, default=DEFAULT_INIT_DIR,
|
||||||
|
help=f"Where to write generated configs (default: {DEFAULT_INIT_DIR}).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run", action="store_true",
|
||||||
|
help="Print generated configs to stdout instead of writing them.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--force", action="store_true",
|
||||||
|
help="Overwrite existing files in the config directory.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-d", "--debug", action="store_true",
|
||||||
|
help="Re-raise exceptions instead of printing a friendly message.",
|
||||||
|
)
|
||||||
|
|
||||||
|
parsed = parser.parse_args(argv)
|
||||||
|
init_args = InitArgs(
|
||||||
|
count=parsed.count,
|
||||||
|
config_dir=parsed.config_dir,
|
||||||
|
dry_run=parsed.dry_run,
|
||||||
|
force=parsed.force,
|
||||||
|
debug=parsed.debug,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
run_init(init_args)
|
||||||
|
except Exception as exc:
|
||||||
|
if init_args.debug:
|
||||||
|
raise
|
||||||
|
print(f"pi3-smart-workspace init: {exc}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# main entry point
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Entry point for the ``pi3-smart-workspace`` console script.
|
||||||
|
|
||||||
|
If invoked as ``pi3-smart-workspace init [...]`` the call is routed to
|
||||||
|
:func:`_init_main`. Otherwise it parses ``sys.argv`` into an
|
||||||
|
:class:`Args` instance and dispatches to :func:`run`. Exceptions are
|
||||||
|
caught and printed as a single-line error on stderr (exit code 1),
|
||||||
|
unless ``--debug`` is set, in which case the traceback is re-raised.
|
||||||
|
"""
|
||||||
|
if len(sys.argv) > 1 and sys.argv[1] == "init":
|
||||||
|
_init_main(sys.argv[2:])
|
||||||
|
return
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Switch (or shift) to the N-th workspace on the output your cursor is on."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-d", "--debug", action="store_true",
|
||||||
|
help="Print parsed state and re-raise exceptions.",
|
||||||
|
)
|
||||||
|
|
||||||
|
required = parser.add_argument_group("Required")
|
||||||
|
required.add_argument(
|
||||||
|
"-i", "--index", type=int, required=True,
|
||||||
|
help="The 1-based index into the workspaces declared for the cursor's output.",
|
||||||
|
)
|
||||||
|
|
||||||
|
shift_group = parser.add_argument_group("Shift", "manipulate the active window")
|
||||||
|
shift_group.add_argument(
|
||||||
|
"-s", "--shift", action="store_true",
|
||||||
|
help="Move the focused container to the indexed workspace.",
|
||||||
|
)
|
||||||
|
shift_group.add_argument(
|
||||||
|
"-k", "--keep-with-it", action="store_true",
|
||||||
|
help="With --shift, also follow the container to the new workspace.",
|
||||||
|
)
|
||||||
|
|
||||||
|
parsed = parser.parse_args()
|
||||||
|
args = Args(
|
||||||
|
index=parsed.index,
|
||||||
|
shift=parsed.shift,
|
||||||
|
keep_with_it=parsed.keep_with_it,
|
||||||
|
debug=parsed.debug,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
run(args)
|
||||||
|
except Exception as exc:
|
||||||
|
if args.debug:
|
||||||
|
raise
|
||||||
|
print(f"pi3-smart-workspace: {exc}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
57
pyproject.toml
Normal file
57
pyproject.toml
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "pi3-smart-workspace"
|
||||||
|
version = "0.2.0"
|
||||||
|
description = "Switch i3 workspaces based on which output your mouse cursor is on."
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.14"
|
||||||
|
license = "Apache-2.0"
|
||||||
|
authors = [
|
||||||
|
{ name = "Asger Geel Weirsøe", email = "asger@weircon.dk" },
|
||||||
|
]
|
||||||
|
keywords = ["i3", "i3wm", "workspace", "multi-monitor", "x11"]
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 4 - Beta",
|
||||||
|
"Environment :: X11 Applications",
|
||||||
|
"Intended Audience :: End Users/Desktop",
|
||||||
|
"License :: OSI Approved :: Apache Software License",
|
||||||
|
"Operating System :: POSIX :: Linux",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.14",
|
||||||
|
"Topic :: Desktop Environment :: Window Managers",
|
||||||
|
]
|
||||||
|
dependencies = [
|
||||||
|
"i3ipc>=2.2.1",
|
||||||
|
"pynput>=1.7.6",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0",
|
||||||
|
"build>=1.2",
|
||||||
|
"twine>=5.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
pi3-smart-workspace = "pi3.smart_workspace:main"
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Homepage = "https://gitea.weircon.dk/agw/pi3-smart-workspace"
|
||||||
|
Issues = "https://gitea.weircon.dk/agw/pi3-smart-workspace/issues"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["pi3"]
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.sdist]
|
||||||
|
include = [
|
||||||
|
"/pi3",
|
||||||
|
"/tests",
|
||||||
|
"/README.md",
|
||||||
|
"/LICENSE",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
evdev==1.3.0
|
|
||||||
i3ipc==2.2.1
|
|
||||||
pynput==1.7.1
|
|
||||||
python-xlib==0.27
|
|
||||||
six==1.15.0
|
|
||||||
32
setup.py
32
setup.py
@@ -1,32 +0,0 @@
|
|||||||
from setuptools import setup
|
|
||||||
with open('README.rst', 'r') as fh:
|
|
||||||
long_description = fh.read()
|
|
||||||
|
|
||||||
setup(
|
|
||||||
name='pi3-smart-workspace',
|
|
||||||
version='0.1.4',
|
|
||||||
packages=['pi3'],
|
|
||||||
url='https://github.com/GeneralDenmark/PyOutputHandler',
|
|
||||||
license='Apache-2.0 License ',
|
|
||||||
install_requires=[
|
|
||||||
"evdev==1.3.0",
|
|
||||||
"i3ipc==2.2.1",
|
|
||||||
"pynput==1.7.1",
|
|
||||||
"python-xlib==0.27",
|
|
||||||
"six==1.15.0"
|
|
||||||
],
|
|
||||||
entry_points={"console_scripts": ["pi3-smart-workspace=pi3.smart_workspace:main"]},
|
|
||||||
scripts=["pi3/smart_workspace.py"],
|
|
||||||
long_description=long_description,
|
|
||||||
long_description_content_type='text/x-rst',
|
|
||||||
author='Asger Geel Weirsøe',
|
|
||||||
author_email='asger@weirsoe.dk',
|
|
||||||
description='Simple program that looks through the i3 config and finds the bound workspaces for each output, and then opening that workspace on the output, that the mouse is currently on.',
|
|
||||||
classifiers=[
|
|
||||||
"Development Status :: 4 - Alpha",
|
|
||||||
"License :: OSI Approved :: Apache Software License",
|
|
||||||
"Operating System :: POSIX :: Linux",
|
|
||||||
"Programming Language :: Python :: 3",
|
|
||||||
"Topic :: Desktop Environment :: Window Managers",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
400
tests/test_smart_workspace.py
Normal file
400
tests/test_smart_workspace.py
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
from textwrap import dedent
|
||||||
|
|
||||||
|
from pi3.smart_workspace import (
|
||||||
|
Rect,
|
||||||
|
_planned_files,
|
||||||
|
expand_i3_includes,
|
||||||
|
find_output_at_cursor,
|
||||||
|
parse_workspaces_per_output,
|
||||||
|
render_bindings,
|
||||||
|
render_workspaces_for_output,
|
||||||
|
sanitize_var_suffix,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# parse_workspaces_per_output
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_parses_readme_example():
|
||||||
|
"""The full README.md example config round-trips through the parser."""
|
||||||
|
config = dedent("""
|
||||||
|
# Displays
|
||||||
|
set $primary eDP
|
||||||
|
set $top HDMI-A-0
|
||||||
|
set $bottom HDMI2
|
||||||
|
|
||||||
|
# Workspaces
|
||||||
|
set $ws1 1:1
|
||||||
|
set $ws2 2:2
|
||||||
|
set $TopWs1 3:1
|
||||||
|
set $TopWs2 4:2
|
||||||
|
set $BottomWs1 5:1
|
||||||
|
|
||||||
|
workspace $ws1 output $primary
|
||||||
|
workspace $ws2 output $primary
|
||||||
|
workspace $TopWs1 output $top
|
||||||
|
workspace $TopWs2 output $top
|
||||||
|
workspace $BottomWs1 output $bottom
|
||||||
|
""")
|
||||||
|
|
||||||
|
result = parse_workspaces_per_output(config, ["eDP", "HDMI-A-0", "HDMI2"])
|
||||||
|
|
||||||
|
assert result == {
|
||||||
|
"eDP": ["1:1", "2:2"],
|
||||||
|
"HDMI-A-0": ["3:1", "4:2"],
|
||||||
|
"HDMI2": ["5:1"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_inactive_output_is_skipped():
|
||||||
|
config = dedent("""
|
||||||
|
set $primary eDP
|
||||||
|
set $external HDMI2
|
||||||
|
set $ws1 1:1
|
||||||
|
set $extWs1 9:ext
|
||||||
|
workspace $ws1 output $primary
|
||||||
|
workspace $extWs1 output $external
|
||||||
|
""")
|
||||||
|
|
||||||
|
# HDMI2 is unplugged
|
||||||
|
result = parse_workspaces_per_output(config, ["eDP"])
|
||||||
|
|
||||||
|
assert result == {"eDP": ["1:1"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_declaration_order_is_preserved():
|
||||||
|
config = dedent("""
|
||||||
|
set $out eDP
|
||||||
|
set $a 3:c
|
||||||
|
set $b 1:a
|
||||||
|
set $c 2:b
|
||||||
|
workspace $b output $out
|
||||||
|
workspace $c output $out
|
||||||
|
workspace $a output $out
|
||||||
|
""")
|
||||||
|
|
||||||
|
result = parse_workspaces_per_output(config, ["eDP"])
|
||||||
|
|
||||||
|
assert result == {"eDP": ["1:a", "2:b", "3:c"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_unrelated_set_lines_are_ignored():
|
||||||
|
config = dedent("""
|
||||||
|
set $primary eDP
|
||||||
|
set $ws1 1:1
|
||||||
|
set $term i3-sensible-terminal
|
||||||
|
set $launcher dmenu_run
|
||||||
|
set $mod Mod4
|
||||||
|
workspace $ws1 output $primary
|
||||||
|
""")
|
||||||
|
|
||||||
|
result = parse_workspaces_per_output(config, ["eDP"])
|
||||||
|
|
||||||
|
assert result == {"eDP": ["1:1"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_active_outputs_returns_empty():
|
||||||
|
config = "set $primary eDP\nset $ws1 1:1\nworkspace $ws1 output $primary\n"
|
||||||
|
|
||||||
|
assert parse_workspaces_per_output(config, []) == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_binding_with_unknown_var_is_skipped():
|
||||||
|
config = dedent("""
|
||||||
|
set $primary eDP
|
||||||
|
set $ws1 1:1
|
||||||
|
workspace $ws1 output $unknownOutput
|
||||||
|
workspace $unknownWs output $primary
|
||||||
|
""")
|
||||||
|
|
||||||
|
assert parse_workspaces_per_output(config, ["eDP"]) == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_output_name_with_special_regex_chars_is_escaped():
|
||||||
|
# DisplayPort outputs often look like DP-1, HDMI-A-0 — regex-sensitive
|
||||||
|
config = dedent("""
|
||||||
|
set $main DP-1
|
||||||
|
set $ws1 1:1
|
||||||
|
workspace $ws1 output $main
|
||||||
|
""")
|
||||||
|
|
||||||
|
assert parse_workspaces_per_output(config, ["DP-1"]) == {"DP-1": ["1:1"]}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# find_output_at_cursor
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _outputs(*specs):
|
||||||
|
return [(name, Rect(*rect)) for name, rect in specs]
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_output_contains_origin():
|
||||||
|
outputs = _outputs(("eDP", (0, 0, 1920, 1080)))
|
||||||
|
|
||||||
|
assert find_output_at_cursor(outputs, (0, 0)) == "eDP"
|
||||||
|
assert find_output_at_cursor(outputs, (960, 540)) == "eDP"
|
||||||
|
assert find_output_at_cursor(outputs, (1920, 1080)) == "eDP"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cursor_outside_all_outputs_returns_none():
|
||||||
|
outputs = _outputs(("eDP", (0, 0, 1920, 1080)))
|
||||||
|
|
||||||
|
assert find_output_at_cursor(outputs, (3000, 2000)) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_side_by_side_outputs_shared_vertical_edge_belongs_to_left():
|
||||||
|
# Primary at origin, secondary directly to the right of it.
|
||||||
|
outputs = _outputs(
|
||||||
|
("eDP", (0, 0, 1920, 1080)),
|
||||||
|
("HDMI", (1920, 0, 1920, 1080)),
|
||||||
|
)
|
||||||
|
|
||||||
|
# On the shared edge x=1920 → belongs to the LEFT output.
|
||||||
|
assert find_output_at_cursor(outputs, (1920, 500)) == "eDP"
|
||||||
|
# Just past it → right output.
|
||||||
|
assert find_output_at_cursor(outputs, (1921, 500)) == "HDMI"
|
||||||
|
# Deep inside right output.
|
||||||
|
assert find_output_at_cursor(outputs, (3000, 500)) == "HDMI"
|
||||||
|
|
||||||
|
|
||||||
|
def test_stacked_outputs_shared_horizontal_edge_belongs_to_top():
|
||||||
|
outputs = _outputs(
|
||||||
|
("eDP", (0, 0, 1920, 1080)),
|
||||||
|
("HDMI", (0, 1080, 1920, 1080)),
|
||||||
|
)
|
||||||
|
|
||||||
|
# On shared edge y=1080 → top output.
|
||||||
|
assert find_output_at_cursor(outputs, (500, 1080)) == "eDP"
|
||||||
|
# Just past it → bottom output.
|
||||||
|
assert find_output_at_cursor(outputs, (500, 1081)) == "HDMI"
|
||||||
|
|
||||||
|
|
||||||
|
def test_iteration_order_does_not_change_edge_attribution():
|
||||||
|
# Same as side-by-side, but with outputs declared in opposite order.
|
||||||
|
outputs = _outputs(
|
||||||
|
("HDMI", (1920, 0, 1920, 1080)),
|
||||||
|
("eDP", (0, 0, 1920, 1080)),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert find_output_at_cursor(outputs, (1920, 500)) == "eDP"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Quoted workspace values (e.g. set $ws1 "1")
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_parses_quoted_workspace_values():
|
||||||
|
"""i3 configs commonly use 'set $ws1 "1"' — the parser must strip quotes."""
|
||||||
|
config = dedent('''
|
||||||
|
set $primary eDP
|
||||||
|
set $ws1 "1"
|
||||||
|
set $ws2 "2:web"
|
||||||
|
workspace $ws1 output $primary
|
||||||
|
workspace $ws2 output $primary
|
||||||
|
''')
|
||||||
|
|
||||||
|
assert parse_workspaces_per_output(config, ["eDP"]) == {"eDP": ["1", "2:web"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_quoted_and_unquoted_can_mix():
|
||||||
|
config = dedent('''
|
||||||
|
set $primary eDP
|
||||||
|
set $ws1 "1"
|
||||||
|
set $ws2 2:b
|
||||||
|
workspace $ws1 output $primary
|
||||||
|
workspace $ws2 output $primary
|
||||||
|
''')
|
||||||
|
|
||||||
|
assert parse_workspaces_per_output(config, ["eDP"]) == {"eDP": ["1", "2:b"]}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# init rendering
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_var_suffix_replaces_non_alnum():
|
||||||
|
assert sanitize_var_suffix("eDP-1") == "eDP_1"
|
||||||
|
assert sanitize_var_suffix("HDMI-A-0") == "HDMI_A_0"
|
||||||
|
assert sanitize_var_suffix("DVI-I-2-2") == "DVI_I_2_2"
|
||||||
|
assert sanitize_var_suffix("plain") == "plain"
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_workspaces_for_output_uses_global_offset():
|
||||||
|
out = render_workspaces_for_output("HDMI-A-0", global_start=5, count=3)
|
||||||
|
|
||||||
|
# Global numbers continue from 5; local labels reset to 1.
|
||||||
|
assert "set $ws_HDMI_A_0_1 5:1" in out
|
||||||
|
assert "set $ws_HDMI_A_0_2 6:2" in out
|
||||||
|
assert "set $ws_HDMI_A_0_3 7:3" in out
|
||||||
|
# Output alias is present and uses the sanitized var name.
|
||||||
|
assert "set $out_HDMI_A_0 HDMI-A-0" in out
|
||||||
|
# All three workspaces are bound to the output.
|
||||||
|
assert out.count("output $out_HDMI_A_0") == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_workspaces_rejects_bad_args():
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
render_workspaces_for_output("eDP-1", global_start=0, count=8)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
render_workspaces_for_output("eDP-1", global_start=1, count=0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_bindings_emits_three_blocks():
|
||||||
|
out = render_bindings(count=2)
|
||||||
|
|
||||||
|
assert "bindsym $mod+1 exec --no-startup-id pi3-smart-workspace -i 1" in out
|
||||||
|
assert "bindsym $mod+2 exec --no-startup-id pi3-smart-workspace -i 2" in out
|
||||||
|
assert "bindsym $mod+Shift+1 exec --no-startup-id pi3-smart-workspace -i 1 -s" in out
|
||||||
|
assert "bindsym $mod+Ctrl+2 exec --no-startup-id pi3-smart-workspace -i 2 -s -k" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_bindings_honors_custom_mod():
|
||||||
|
out = render_bindings(count=1, mod="$myMod")
|
||||||
|
|
||||||
|
assert "$myMod+1" in out
|
||||||
|
assert "$mod+1" not in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_planned_files_assigns_contiguous_global_ranges():
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
outputs = [
|
||||||
|
("eDP-1", Rect(0, 0, 1920, 1080)),
|
||||||
|
("HDMI-A-0", Rect(1920, 0, 1920, 1080)),
|
||||||
|
("DP-1", Rect(3840, 0, 1920, 1080)),
|
||||||
|
]
|
||||||
|
|
||||||
|
files = _planned_files(Path("/tmp/x"), outputs, count=3)
|
||||||
|
|
||||||
|
# First file is the shared bindings.conf, then one workspaces.conf per output.
|
||||||
|
paths = [p for p, _ in files]
|
||||||
|
assert paths == [
|
||||||
|
Path("/tmp/x/bindings.conf"),
|
||||||
|
Path("/tmp/x/eDP-1/workspaces.conf"),
|
||||||
|
Path("/tmp/x/HDMI-A-0/workspaces.conf"),
|
||||||
|
Path("/tmp/x/DP-1/workspaces.conf"),
|
||||||
|
]
|
||||||
|
|
||||||
|
edp = files[1][1]
|
||||||
|
hdmi = files[2][1]
|
||||||
|
dp = files[3][1]
|
||||||
|
|
||||||
|
assert "set $ws_eDP_1_1 1:1" in edp
|
||||||
|
assert "set $ws_HDMI_A_0_1 4:1" in hdmi # 3 (count) + 1
|
||||||
|
assert "set $ws_DP_1_1 7:1" in dp # 6 + 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# expand_i3_includes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_expand_includes_inlines_absolute_path(tmp_path):
|
||||||
|
inc = tmp_path / "inc.conf"
|
||||||
|
inc.write_text("set $foo bar\n")
|
||||||
|
main = f"# top\ninclude {inc}\n# tail\n"
|
||||||
|
|
||||||
|
result = expand_i3_includes(main, tmp_path)
|
||||||
|
|
||||||
|
assert "set $foo bar" in result
|
||||||
|
assert "include " not in result # directive consumed
|
||||||
|
assert "# top" in result and "# tail" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_expand_includes_handles_glob_in_alphabetical_order(tmp_path):
|
||||||
|
(tmp_path / "20-b.conf").write_text("# B\n")
|
||||||
|
(tmp_path / "10-a.conf").write_text("# A\n")
|
||||||
|
(tmp_path / "30-c.conf").write_text("# C\n")
|
||||||
|
main = f"include {tmp_path}/*.conf\n"
|
||||||
|
|
||||||
|
result = expand_i3_includes(main, tmp_path)
|
||||||
|
|
||||||
|
# Glob results are sorted, so A appears before B which appears before C.
|
||||||
|
assert result.index("# A") < result.index("# B") < result.index("# C")
|
||||||
|
|
||||||
|
|
||||||
|
def test_expand_includes_resolves_relative_paths_against_base_dir(tmp_path):
|
||||||
|
(tmp_path / "sub.conf").write_text("set $rel ok\n")
|
||||||
|
main = "include sub.conf\n"
|
||||||
|
|
||||||
|
result = expand_i3_includes(main, tmp_path)
|
||||||
|
|
||||||
|
assert "set $rel ok" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_expand_includes_recurses_through_chained_includes(tmp_path):
|
||||||
|
(tmp_path / "a.conf").write_text(f"include {tmp_path}/b.conf\n")
|
||||||
|
(tmp_path / "b.conf").write_text("set $deep yes\n")
|
||||||
|
main = f"include {tmp_path}/a.conf\n"
|
||||||
|
|
||||||
|
result = expand_i3_includes(main, tmp_path)
|
||||||
|
|
||||||
|
assert "set $deep yes" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_expand_includes_breaks_self_referential_cycle(tmp_path):
|
||||||
|
a = tmp_path / "a.conf"
|
||||||
|
a.write_text(f"set $marker present\ninclude {a}\n")
|
||||||
|
main = f"include {a}\n"
|
||||||
|
|
||||||
|
# Without cycle protection this would recurse forever.
|
||||||
|
result = expand_i3_includes(main, tmp_path)
|
||||||
|
|
||||||
|
assert result.count("set $marker present") == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_expand_includes_tolerates_missing_files(tmp_path):
|
||||||
|
main = f"set $before yes\ninclude {tmp_path}/does-not-exist.conf\nset $after yes\n"
|
||||||
|
|
||||||
|
# Glob simply yields nothing — must not raise.
|
||||||
|
result = expand_i3_includes(main, tmp_path)
|
||||||
|
|
||||||
|
assert "set $before yes" in result and "set $after yes" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_expand_then_parse_finds_workspaces_from_included_files(tmp_path):
|
||||||
|
"""The end-to-end fix: workspaces declared in an included file must
|
||||||
|
end up in parse_workspaces_per_output's output."""
|
||||||
|
(tmp_path / "out.conf").write_text(
|
||||||
|
"set $primary eDP\n"
|
||||||
|
"set $ws1 1:1\n"
|
||||||
|
"set $ws2 2:2\n"
|
||||||
|
"workspace $ws1 output $primary\n"
|
||||||
|
"workspace $ws2 output $primary\n"
|
||||||
|
)
|
||||||
|
main = f"set $mod Mod4\ninclude {tmp_path}/out.conf\n"
|
||||||
|
|
||||||
|
expanded = expand_i3_includes(main, tmp_path)
|
||||||
|
result = parse_workspaces_per_output(expanded, ["eDP"])
|
||||||
|
|
||||||
|
assert result == {"eDP": ["1:1", "2:2"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_output_parses_back_through_parser():
|
||||||
|
"""Round-trip: what `init` generates is parseable by `parse_workspaces_per_output`."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
outputs = [
|
||||||
|
("eDP-1", Rect(0, 0, 1920, 1080)),
|
||||||
|
("HDMI-A-0", Rect(1920, 0, 1920, 1080)),
|
||||||
|
]
|
||||||
|
files = _planned_files(Path("/anywhere"), outputs, count=3)
|
||||||
|
|
||||||
|
# Concatenate everything except bindings.conf (parser only cares about
|
||||||
|
# the workspace/output declarations).
|
||||||
|
combined = "\n".join(content for _, content in files[1:])
|
||||||
|
|
||||||
|
result = parse_workspaces_per_output(combined, ["eDP-1", "HDMI-A-0"])
|
||||||
|
|
||||||
|
assert result == {
|
||||||
|
"eDP-1": ["1:1", "2:2", "3:3"],
|
||||||
|
"HDMI-A-0": ["4:1", "5:2", "6:3"],
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user