8 Commits
Author SHA1 Message Date
Asger WeirsøeandClaude fa1de7fe16 Add init --sync and --prune for incremental monitor setup
`init` planned every file on every run and refused if any of them
existed, so attaching a new monitor could not be configured without
--force — which reallocated global workspace numbers by i3's current
output order and renumbered the monitors already in use.

- allocate_global_starts() keeps the block each known output already
  holds and gives new outputs the lowest free block; blocks of inactive
  outputs stay reserved.
- read_allocations()/parse_allocation() read those blocks back out of
  the generated files (inverse of render_workspaces_for_output).
- --sync writes only the missing files, --prune drops configs for
  outputs that are gone and frees their numbers.
- Files without the auto-generated header are never written or
  deleted, --force included.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 12:58:05 +02:00
Asger Weirsøe 8bbf7b8fbe AUR release 1 2026-05-20 18:54:29 +02:00
Asger Weirsøe f68b70c1da Fixed some bugs, updated how packages are handeled. Prepared on AUR publishing 2026-05-19 20:19:53 +02:00
generaldenmark f3aa794847 Update 2020-11-22 18:54:19 +01:00
generaldenmark 289e2e8465 Merge branch 'master' of github.com:GeneralDenmark/pi3-smart-workspace 2020-11-22 18:53:40 +01:00
generaldenmark 52feda8c10 Fixes problem if you have a number in your output name 2020-11-22 18:51:28 +01:00
Asger Geel Weirsøe 0687a4ab31 Adds a greeter for first time PR's and first time Issues 2020-11-13 21:42:47 +01:00
generaldenmark 2ab92a9149 Updates readme to reflect the current state of the program, and also adds a future work in the readme :9 2020-11-13 20:22:31 +01:00
14 changed files with 1952 additions and 352 deletions
+82
View 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
View 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
View 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'
+19 -21
View File
@@ -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
on:
@@ -9,23 +6,24 @@ on:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: '3.x'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install setuptools wheel twine
- name: Build and publish
env:
TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
run: |
python setup.py sdist bdist_wheel
twine upload --repository pypi dist/*
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.14"
- name: Install build tooling
run: |
python -m pip install --upgrade pip
pip install build twine
- name: Build sdist and wheel
run: python -m build
- name: Publish to PyPI
env:
TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
run: twine upload --repository pypi dist/*
+220
View File
@@ -0,0 +1,220 @@
# 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 |
| `--sync` | off | Only write configs for outputs that don't have one yet |
| `--force` | off | Regenerate every file from scratch (renumbers workspaces) |
| `--prune` | off | Delete generated configs for outputs that are no longer active |
### Plugging in a new monitor
Run:
```sh
pi3-smart-workspace init --sync
```
`--sync` writes a `workspaces.conf` only for the outputs that don't have
one yet, and gives the newcomer the lowest block of global workspace
numbers that isn't already taken. The monitors you already use keep the
numbers they have, so your muscle memory survives — which a plain
`--force` regeneration would not guarantee, since that allocates numbers
by i3's current output order and can shuffle everything.
Configs for monitors that are merely switched off are left in place and
their numbers stay reserved. Add `--prune` to delete them and free those
numbers for a new screen:
```sh
pi3-smart-workspace init --sync --prune
```
Files that don't carry the `# Auto-generated by pi3-smart-workspace init`
header are never overwritten or deleted, not even with `--force` — so
anything you hand-tune in that directory is safe.
### 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
View File
@@ -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
View File
@@ -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
View 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"
}
+860 -122
View File
File diff suppressed because it is too large Load Diff
+57
View File
@@ -0,0 +1,57 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "pi3-smart-workspace"
version = "0.3.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"]
-5
View File
@@ -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
View File
@@ -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.8',
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 :: 3 - Alpha",
"License :: OSI Approved :: Apache Software License",
"Operating System :: POSIX :: Linux",
"Programming Language :: Python :: 3",
"Topic :: Desktop Environment :: Window Managers",
],
)
View File
+653
View File
@@ -0,0 +1,653 @@
from textwrap import dedent
import pytest
from pi3.smart_workspace import (
Allocation,
InitArgs,
Rect,
_planned_files,
allocate_global_starts,
expand_i3_includes,
find_output_at_cursor,
parse_allocation,
parse_workspaces_per_output,
read_allocations,
render_bindings,
render_workspaces_for_output,
run_init,
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"],
}
# ---------------------------------------------------------------------------
# incremental allocation (init --sync)
# ---------------------------------------------------------------------------
def test_parse_allocation_round_trips_render():
text = render_workspaces_for_output("HDMI-A-0", global_start=9, count=8)
assert parse_allocation(text) == Allocation("HDMI-A-0", 9, 8)
def test_parse_allocation_ignores_unrelated_config():
assert parse_allocation("set $mod Mod4\nset $ws1 1:1\n") is None
assert parse_allocation("") is None
def test_allocate_keeps_existing_outputs_and_appends_new():
"""The bug this fixes: a new monitor must not renumber the old ones."""
existing = {"eDP-1": Allocation("eDP-1", 1, 8)}
# i3 reports the newcomer *first*, which used to push eDP-1 to 9..16.
starts = allocate_global_starts(["HDMI-A-0", "eDP-1"], existing, count=8)
assert starts == {"eDP-1": 1, "HDMI-A-0": 9}
def test_allocate_reserves_blocks_of_currently_inactive_outputs():
"""A switched-off monitor keeps its numbers unless it's pruned."""
existing = {
"eDP-1": Allocation("eDP-1", 1, 8),
"HDMI-A-0": Allocation("HDMI-A-0", 9, 8), # unplugged right now
}
starts = allocate_global_starts(["eDP-1", "DP-1"], existing, count=8)
assert starts == {"eDP-1": 1, "DP-1": 17}
def test_allocate_fills_the_hole_left_by_a_pruned_output():
existing = {"eDP-1": Allocation("eDP-1", 9, 8)} # HDMI at 1..8 was pruned
starts = allocate_global_starts(["eDP-1", "DP-1"], existing, count=8)
assert starts == {"eDP-1": 9, "DP-1": 1}
def test_allocate_reallocates_when_the_count_changes():
"""A different -n means the old block no longer fits; it's released."""
existing = {
"eDP-1": Allocation("eDP-1", 1, 8),
"HDMI-A-0": Allocation("HDMI-A-0", 9, 8),
}
starts = allocate_global_starts(["eDP-1", "HDMI-A-0"], existing, count=4)
# Both 8-wide blocks are released and repacked tightly at the new width.
assert starts == {"eDP-1": 1, "HDMI-A-0": 5}
def test_allocate_assigns_from_scratch_when_nothing_exists():
starts = allocate_global_starts(["eDP-1", "HDMI-A-0"], {}, count=3)
assert starts == {"eDP-1": 1, "HDMI-A-0": 4}
def test_planned_files_honors_explicit_starts():
from pathlib import Path
outputs = [("HDMI-A-0", Rect(0, 0, 1920, 1080)), ("eDP-1", Rect(1920, 0, 1920, 1080))]
files = _planned_files(Path("/x"), outputs, count=8, starts={"eDP-1": 1, "HDMI-A-0": 9})
assert "set $ws_HDMI_A_0_1 9:1" in files[1][1]
assert "set $ws_eDP_1_1 1:1" in files[2][1]
def test_read_allocations_skips_hand_written_files(tmp_path):
generated = tmp_path / "eDP-1" / "workspaces.conf"
generated.parent.mkdir()
generated.write_text(render_workspaces_for_output("eDP-1", 1, 8))
handwritten = tmp_path / "DP-1" / "workspaces.conf"
handwritten.parent.mkdir()
handwritten.write_text("set $out_DP_1 DP-1\nset $ws_DP_1_1 1:1\n")
assert read_allocations(tmp_path) == {"eDP-1": Allocation("eDP-1", 1, 8)}
def test_read_allocations_on_missing_directory(tmp_path):
assert read_allocations(tmp_path / "nope") == {}
# ---------------------------------------------------------------------------
# run_init (with a stubbed i3 connection)
# ---------------------------------------------------------------------------
class _FakeOutput:
def __init__(self, name, rect):
self.name = name
self.rect = rect
self.active = True
class _FakeConnection:
def __init__(self, outputs):
self._outputs = outputs
def get_outputs(self):
return self._outputs
@pytest.fixture
def fake_i3(monkeypatch):
"""Install a stub i3 connection reporting the given active outputs."""
import pi3.smart_workspace as sw
def install(*names):
outputs = [
_FakeOutput(name, Rect(i * 1920, 0, 1920, 1080))
for i, name in enumerate(names)
]
monkeypatch.setattr(
sw.i3ipc, "Connection", lambda *a, **kw: _FakeConnection(outputs)
)
return install
def _init_args(config_dir, **overrides):
defaults = dict(
count=8, config_dir=config_dir, dry_run=False, force=False,
sync=False, prune=False, debug=True,
)
return InitArgs(**{**defaults, **overrides})
def test_init_sync_adds_new_monitor_without_renumbering(tmp_path, fake_i3):
fake_i3("eDP-1")
run_init(_init_args(tmp_path))
before = (tmp_path / "eDP-1" / "workspaces.conf").read_text()
# New monitor shows up, and i3 lists it before the existing one.
fake_i3("HDMI-A-0", "eDP-1")
run_init(_init_args(tmp_path, sync=True))
assert (tmp_path / "eDP-1" / "workspaces.conf").read_text() == before
new = (tmp_path / "HDMI-A-0" / "workspaces.conf").read_text()
assert "set $ws_HDMI_A_0_1 9:1" in new
assert "set $ws_HDMI_A_0_8 16:8" in new
def test_init_sync_result_parses_back_without_number_collisions(tmp_path, fake_i3):
fake_i3("eDP-1")
run_init(_init_args(tmp_path, count=3))
fake_i3("HDMI-A-0", "eDP-1")
run_init(_init_args(tmp_path, count=3, sync=True))
combined = "\n".join(
p.read_text() for p in sorted(tmp_path.glob("*/workspaces.conf"))
)
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"]}
# No global workspace number is claimed by two outputs.
numbers = [ws.split(":")[0] for names in result.values() for ws in names]
assert len(numbers) == len(set(numbers))
def test_init_sync_is_a_noop_when_everything_exists(tmp_path, fake_i3):
fake_i3("eDP-1")
run_init(_init_args(tmp_path))
before = {p: p.read_text() for p in tmp_path.rglob("*.conf")}
run_init(_init_args(tmp_path, sync=True))
assert {p: p.read_text() for p in tmp_path.rglob("*.conf")} == before
def test_init_without_sync_still_refuses_to_overwrite(tmp_path, fake_i3):
fake_i3("eDP-1")
run_init(_init_args(tmp_path))
fake_i3("HDMI-A-0", "eDP-1")
with pytest.raises(RuntimeError, match="--sync"):
run_init(_init_args(tmp_path))
def test_init_force_renumbers_by_output_order(tmp_path, fake_i3):
fake_i3("eDP-1")
run_init(_init_args(tmp_path))
fake_i3("HDMI-A-0", "eDP-1")
run_init(_init_args(tmp_path, force=True))
assert "set $ws_HDMI_A_0_1 1:1" in (tmp_path / "HDMI-A-0" / "workspaces.conf").read_text()
assert "set $ws_eDP_1_1 9:1" in (tmp_path / "eDP-1" / "workspaces.conf").read_text()
def test_init_force_refuses_to_clobber_hand_written_files(tmp_path, fake_i3):
fake_i3("eDP-1")
target = tmp_path / "eDP-1" / "workspaces.conf"
target.parent.mkdir(parents=True)
target.write_text("# mine, hand-tuned\nset $out_eDP_1 eDP-1\n")
with pytest.raises(RuntimeError, match="not generated"):
run_init(_init_args(tmp_path, force=True))
assert target.read_text() == "# mine, hand-tuned\nset $out_eDP_1 eDP-1\n"
def test_init_prune_removes_inactive_output_and_frees_its_numbers(tmp_path, fake_i3):
fake_i3("eDP-1", "HDMI-A-0")
run_init(_init_args(tmp_path))
assert (tmp_path / "HDMI-A-0").exists()
# HDMI unplugged, DP-1 plugged in instead.
fake_i3("eDP-1", "DP-1")
run_init(_init_args(tmp_path, sync=True, prune=True))
assert not (tmp_path / "HDMI-A-0").exists()
# DP-1 reuses the block HDMI-A-0 gave up rather than starting at 17.
assert "set $ws_DP_1_1 9:1" in (tmp_path / "DP-1" / "workspaces.conf").read_text()
def test_init_prune_leaves_hand_written_files_alone(tmp_path, fake_i3):
fake_i3("eDP-1")
run_init(_init_args(tmp_path))
handwritten = tmp_path / "HDMI-A-0" / "workspaces.conf"
handwritten.parent.mkdir()
handwritten.write_text("# mine\n")
run_init(_init_args(tmp_path, sync=True, prune=True))
assert handwritten.exists()
def test_init_dry_run_touches_nothing(tmp_path, fake_i3):
fake_i3("eDP-1")
run_init(_init_args(tmp_path, dry_run=True))
assert list(tmp_path.iterdir()) == []