16 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
generaldenmark 2d01e2a214 Last release introduced a bug, where if you had a monitor in your config that was not active, it would error out the script and prevent screenchanges 2020-11-13 20:17:57 +01:00
generaldenmark 4daf458933 Removes the random regex, and just tries to match with what monitors are availble. (remember to update your config to match available outputs dummy) 2020-11-13 20:15:43 +01:00
Asger Geel Weirsøe 7725cf2684 Fixed bug with how the output is detected, and minor spelling fixes 2020-11-12 20:06:37 +01:00
Asger Geel Weirsøe 52b77497a0 setup script 2020-09-15 20:05:08 +02:00
Asger Geel Weirsøe 480c0edfe5 removes debug print 2020-09-15 20:04:16 +02:00
Asger Geel Weirsøe 8f96ead687 fml 2020-09-15 20:03:32 +02:00
Asger Geel Weirsøe e5cff36588 fixes setup version v0.1.4 to new file 2020-09-15 20:02:51 +02:00
Asger Geel Weirsøe 8130cdd29b fixes setup to new file 2020-09-15 20:02:35 +02:00
14 changed files with 1958 additions and 332 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"
}
+866 -102
View File
@@ -1,114 +1,878 @@
from i3ipc import Connection
import sys
import pynput
import re
"""Per-output workspace switcher for the i3 window manager.
This module powers the ``pi3-smart-workspace`` CLI. When invoked with
``-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 glob
import os
import pathlib
import pprint
import re
import sys
from dataclasses import dataclass
from typing import Iterable, Mapping
import i3ipc
class WorkSpacer:
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)
DEFAULT_INIT_DIR = pathlib.Path("~/.config/i3/pi3-smart-workspace")
DEFAULT_WORKSPACES_PER_OUTPUT = 8
def main():
parser = argparse.ArgumentParser(
description="Dynamic changes the workspace, based on what output your cursoer is on."
@dataclass(frozen=True)
class Rect:
"""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', '')
required_group.add_argument("-i", "--index", type=int, required=True,
help="the number index of the workspace that should be openend. 1 = first workspace in config etc.")
output_aliases = {m.group(1): m.group(2) for m in output_alias_re.finditer(config_text)}
# Quoted form lives in group 2 (without surrounding quotes), unquoted in
# 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')
shift_group.add_argument("-s", "--shift", action='store_true',
help="if present, moves the current active window to target workspace")
shift_group.add_argument('-k', '--keep-with-it', action='store_true',
help='if present, moves with the ')
pprint.pprint(parser.parse_args().__dict__)
WorkSpacer(parser.parse_args()).run()
result: dict[str, list[str]] = {}
for match in binding_re.finditer(config_text):
ws_var, out_var = match.group(1), match.group(2)
# Either side of the binding may reference a variable we didn't
# match (e.g. an unplugged output) — drop the binding silently.
if out_var not in output_aliases or ws_var not in workspace_aliases:
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 Allocation:
"""A block of global workspace numbers already handed to an output.
Attributes:
output: The output name the block belongs to.
start: First global workspace number in the block.
count: How many consecutive numbers the block covers.
"""
output: str
start: int
count: int
_ALLOC_OUTPUT_RE = re.compile(r"^\s*set\s+\$out_\w+\s+(\S+)\s*$", re.MULTILINE)
_ALLOC_WS_RE = re.compile(r"^\s*set\s+\$ws_\w+\s+(\d+):\d+\s*$", re.MULTILINE)
def parse_allocation(config_text: str) -> Allocation | None:
"""Read back the output and global-number block from a generated file.
The inverse of :func:`render_workspaces_for_output`, used by
``init --sync`` to keep the numbers a monitor already has instead of
reallocating them from scratch.
Args:
config_text: Contents of a ``workspaces.conf`` previously written
by ``init``.
Returns:
The :class:`Allocation` described by the file, or ``None`` if the
text doesn't contain both an ``$out_…`` alias and at least one
``$ws_…`` alias (i.e. it isn't one of our generated files).
"""
output_match = _ALLOC_OUTPUT_RE.search(config_text)
numbers = [int(m.group(1)) for m in _ALLOC_WS_RE.finditer(config_text)]
if output_match is None or not numbers:
return None
return Allocation(output_match.group(1), min(numbers), len(numbers))
def allocate_global_starts(
outputs: Iterable[str],
existing: Mapping[str, Allocation],
count: int,
) -> dict[str, int]:
"""Decide each output's first global workspace number.
Outputs that already hold a block of the right size keep it, so
plugging in a new monitor never renumbers the ones you already use.
New outputs get the lowest block of ``count`` free numbers.
Blocks belonging to outputs in ``existing`` that aren't in ``outputs``
(an unplugged monitor) stay reserved — a screen that is merely
switched off must not lose its numbers to a newcomer. Drop those
entries from ``existing`` (what ``--prune`` does) to free them.
Args:
outputs: Names of the outputs to allocate for, in i3's order.
existing: Already-allocated blocks, keyed by output name.
count: Workspaces per output.
Returns:
``{output_name: global_start}`` covering every name in ``outputs``.
"""
if count < 1:
raise ValueError("count must be >= 1")
occupied: set[int] = set()
for alloc in existing.values():
occupied.update(range(alloc.start, alloc.start + alloc.count))
starts: dict[str, int] = {}
pending: list[str] = []
for name in outputs:
alloc = existing.get(name)
if alloc is not None and alloc.count == count:
starts[name] = alloc.start
else:
if alloc is not None:
# The requested block size changed (``-n``), so the old
# block can't be kept — release it and reallocate below.
occupied.difference_update(range(alloc.start, alloc.start + alloc.count))
pending.append(name)
for name in pending:
start = 1
while not occupied.isdisjoint(range(start, start + count)):
start += 1
starts[name] = start
occupied.update(range(start, start + count))
return starts
@dataclass(frozen=True)
class InitArgs:
"""Parsed CLI arguments for the ``init`` subcommand."""
count: int
config_dir: pathlib.Path
dry_run: bool
force: bool
sync: bool
prune: bool
debug: bool
def _planned_files(
config_dir: pathlib.Path,
outputs: list[tuple[str, Rect]],
count: int,
starts: Mapping[str, int] | None = None,
) -> 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``.
Args:
config_dir: Directory the generated files live in.
outputs: Active outputs, in i3's order.
count: Workspaces per output.
starts: Optional ``{output: global_start}`` map, typically from
:func:`allocate_global_starts`. When omitted, each output gets
a contiguous block based on its position in ``outputs`` —
deterministic, but it renumbers everything if the output order
changes.
"""
files: list[tuple[pathlib.Path, str]] = [
(config_dir / "bindings.conf", render_bindings(count)),
]
for index, (output_name, _) in enumerate(outputs):
if starts is not None:
global_start = starts[output_name]
else:
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 _is_generated(path: pathlib.Path) -> bool:
"""Return whether ``path`` carries our auto-generation header.
Anything else is treated as hand-written and never overwritten or
deleted, not even with ``--force``.
"""
try:
return path.read_text().startswith(_AUTOGEN_HEADER)
except OSError:
return False
def read_allocations(config_dir: pathlib.Path) -> dict[str, Allocation]:
"""Read the global-number blocks already handed out under ``config_dir``.
Scans ``<config_dir>/*/workspaces.conf`` and parses each generated
file with :func:`parse_allocation`. Hand-edited or unreadable files
are skipped, so their numbers are *not* considered reserved.
Args:
config_dir: The (possibly non-existent) init config directory.
Returns:
``{output_name: Allocation}``; empty if nothing has been generated
yet.
"""
allocations: dict[str, Allocation] = {}
for path in sorted(config_dir.glob("*/workspaces.conf")):
if not _is_generated(path):
continue
try:
alloc = parse_allocation(path.read_text())
except OSError:
continue
if alloc is not None:
allocations[alloc.output] = alloc
return allocations
def _prune_paths(config_dir: pathlib.Path, stale: Iterable[str]) -> list[pathlib.Path]:
"""Return the generated ``workspaces.conf`` files of no-longer-active outputs."""
paths = []
for name in stale:
path = config_dir / name / "workspaces.conf"
if path.exists() and _is_generated(path):
paths.append(path)
return paths
def run_init(args: InitArgs) -> None:
"""Generate per-output i3 workspace configs for the currently-active outputs.
Three write modes:
* default — write everything, but refuse if any target file exists.
* ``--sync`` — write only the files that are missing (the new
monitor), leaving existing outputs' numbers untouched.
* ``--force`` — regenerate everything from scratch, which reallocates
global workspace numbers by i3's current output order.
Args:
args: Parsed init-subcommand arguments.
Raises:
RuntimeError: If no active outputs are detected, if files already
exist and neither ``--sync`` nor ``--force`` was passed, or if
a target file exists but wasn't generated by us.
"""
i3 = i3ipc.Connection()
outputs = _active_outputs(i3)
if not outputs:
raise RuntimeError("no active outputs detected by i3")
config_dir = args.config_dir.expanduser()
active_names = [name for name, _ in outputs]
allocations = read_allocations(config_dir)
stale = [name for name in allocations if name not in active_names]
if args.force:
# Explicit "start over": numbers follow i3's output order.
starts = None
else:
if args.prune:
for name in stale:
allocations.pop(name)
starts = allocate_global_starts(active_names, allocations, args.count)
files = _planned_files(config_dir, outputs, args.count, starts)
pruned = _prune_paths(config_dir, stale) if args.prune else []
if args.sync:
planned = [(path, content) for path, content in files if not path.exists()]
else:
planned = files
if args.dry_run:
for path in pruned:
print(f"--- would remove {path} ---")
for path, content in planned:
print(f"--- {path} ---")
print(content, end="")
if not planned:
print("nothing to write; every active output already has a config")
return
existing = [path for path, _ in planned 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 --sync to only add "
"configs for new outputs, or --force to replace):\n " + listing
)
# Never clobber something the user wrote by hand — --force included.
hand_written = [path for path in existing if not _is_generated(path)]
if hand_written:
listing = "\n ".join(str(p) for p in hand_written)
raise RuntimeError(
"refusing to overwrite files that were not generated by "
"`pi3-smart-workspace init` (move them aside first):\n " + listing
)
for path, content in planned:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content)
print(f"wrote {path}")
for path in pruned:
path.unlink()
print(f"removed {path}")
try:
path.parent.rmdir()
except OSError:
pass # directory not empty — leave whatever else is in there
if not planned:
print("nothing to write; every active output already has a config")
if args.sync and stale and not args.prune:
listing = ", ".join(stale)
print(f"note: configs kept for inactive outputs: {listing} (--prune removes them)")
if args.sync:
bindings, bindings_content = files[0]
if bindings.exists() and _is_generated(bindings):
if bindings.read_text() != bindings_content:
print(
f"note: {bindings} differs from what -n {args.count} would "
"generate; pass --force to regenerate it"
)
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.",
)
mode = parser.add_mutually_exclusive_group()
mode.add_argument(
"--sync", action="store_true",
help="Only write configs for outputs that don't have one yet, keeping "
"the workspace numbers of the outputs that do. Use this after "
"plugging in a new monitor.",
)
mode.add_argument(
"--force", action="store_true",
help="Regenerate every file from scratch. This reallocates global "
"workspace numbers by i3's current output order.",
)
parser.add_argument(
"--prune", action="store_true",
help="Also delete generated configs for outputs that are no longer "
"active, freeing their workspace numbers.",
)
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,
sync=parsed.sync,
prune=parsed.prune,
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()
+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.2',
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()) == []