15 Commits

Author SHA1 Message Date
Asger Weirsøe
285bf3e59c AUR release 1
All checks were successful
Publish to AUR / aur (push) Successful in 39s
2026-05-20 09:09:33 +02:00
Asger Weirsøe
0d8f843814 Fixed some bugs, updated how packages are handeled. Prepared on AUR publishing
All checks were successful
CI / test (push) Successful in 40s
2026-05-19 15:59:28 +02:00
generaldenmark
5489847475 Update 2020-11-22 18:54:19 +01:00
generaldenmark
87c1cbff73 Merge branch 'master' of github.com:GeneralDenmark/pi3-smart-workspace 2020-11-22 18:53:40 +01:00
generaldenmark
4a9f520433 Fixes problem if you have a number in your output name 2020-11-22 18:51:28 +01:00
Asger Geel Weirsøe
b68b481b7c Adds a greeter for first time PR's and first time Issues 2020-11-13 22:37:23 +01:00
generaldenmark
ce78dd2e7c Updates readme to reflect the current state of the program, and also adds a future work in the readme :9 2020-11-13 16:12:05 +01:00
generaldenmark
2f455a6a5e 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 15:50:12 +01:00
generaldenmark
57fe74e4d1 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 15:39:31 +01:00
Asger Geel Weirsøe
46b6c1bd56 Fixed bug with how the output is detected, and minor spelling fixes 2020-11-12 14:55:47 +01:00
Asger Geel Weirsøe
230243cfec setup script 2020-09-15 14:48:43 +02:00
Asger Geel Weirsøe
4381c24a82 removes debug print 2020-09-15 14:44:32 +02:00
Asger Geel Weirsøe
57b3328ebe fml 2020-09-15 14:41:00 +02:00
Asger Geel Weirsøe
280436f050 fixes setup version v0.1.4 to new file 2020-09-15 14:37:41 +02:00
Asger Geel Weirsøe
7b7d371332 fixes setup to new file 2020-09-15 14:36:28 +02:00
14 changed files with 1441 additions and 332 deletions

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
.github/workflows/ci.yml vendored Normal file
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
.github/workflows/greetings.yml vendored Normal file
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'

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
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Install dependencies
python-version: "3.14"
- name: Install build tooling
run: |
python -m pip install --upgrade pip
pip install setuptools wheel twine
- name: Build and publish
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: |
python setup.py sdist bdist_wheel
twine upload --repository pypi dist/*
run: twine upload --repository pypi dist/*

194
README.md Normal file
View File

@@ -0,0 +1,194 @@
# pi3-smart-workspace
A small CLI helper for the [i3 window manager](https://i3wm.org/). Bound to
`$mod+<n>` keys, it switches (or moves a container to) the **N-th workspace
declared for the output your mouse cursor is currently on**, rather than
i3's default behaviour of jumping to a globally-numbered workspace.
This makes multi-monitor i3 setups feel a lot more seamless: pressing
`$mod+1` always lands you on the first workspace of whichever screen you're
pointing at.
## Usage
```
usage: pi3-smart-workspace [-h] [-d] -i INDEX [-s] [-k]
Switch (or shift) to the N-th workspace on the output your cursor is on.
options:
-h, --help show this help message and exit
-d, --debug Print parsed state and re-raise exceptions.
Required:
-i INDEX, --index INDEX
The 1-based index into the workspaces declared for
the cursor's output.
Shift:
manipulate the active window
-s, --shift Move the focused container to the indexed workspace.
-k, --keep-with-it With --shift, also follow the container to the new
workspace.
```
## Installation
Install from PyPI:
```sh
pip install pi3-smart-workspace
```
For local development, clone the repo and install in editable mode with the
dev extras:
```sh
pip install -e ".[dev]"
pytest
```
Requires Python 3.14+ and a running i3 session.
## i3 configuration
`pi3-smart-workspace` parses your live i3 config and looks for three
specific patterns:
1. `set $<var> <output-name>` — output aliases
2. `set $<var> <digit…>` — workspace-name aliases (quoted or unquoted)
3. `workspace $<var> output $<var>` — bindings of workspaces to outputs
The order in which `workspace … output …` lines appear in your config
determines the N-th workspace for that output.
### Bootstrap with `init` (recommended)
Instead of writing the config by hand, run:
```sh
pi3-smart-workspace init
```
This detects every active monitor via i3 and writes:
```
~/.config/i3/pi3-smart-workspace/
├── bindings.conf # $mod+N, $mod+Shift+N, $mod+Ctrl+N bindings
├── eDP-1/
│ └── workspaces.conf # workspaces 1..8 bound to eDP-1
├── HDMI-A-0/
│ └── workspaces.conf # workspaces 9..16 bound to HDMI-A-0
└── DP-1/
└── workspaces.conf # workspaces 17..24 bound to DP-1
```
Each output gets a contiguous range of global workspace numbers; the
local index (the number after the colon, e.g. `9:1`) tells you "first
workspace on this monitor". Then add the include lines `init` prints to
your main i3 config and reload (`$mod+Shift+r`):
```i3config
include ~/.config/i3/pi3-smart-workspace/bindings.conf
include ~/.config/i3/pi3-smart-workspace/*/workspaces.conf
```
i3's `include` directive needs i3 ≥ 4.20.
Useful flags:
| Flag | Default | Effect |
| --- | --- | --- |
| `-n N` / `--count N` | 8 | Workspaces per output |
| `--config-dir PATH` | `~/.config/i3/pi3-smart-workspace` | Where to write |
| `--dry-run` | off | Print to stdout, don't touch the filesystem |
| `--force` | off | Overwrite existing files |
Re-run `init --force` after plugging or unplugging a monitor to
regenerate the per-output folders.
### Example config (manual)
```i3config
# Displays
set $primary eDP
set $top HDMI-A-0
set $bottom HDMI2
# Workspaces
set $ws1 1:1
# ... and so on
set $ws{n} {n}:{n}
set $TopWs1 {n+1}:1
# ... and so on
set $TopWs{k} {n+1+k}:{k}
set $BottomWs1 {k+1}:1
# ... and so on
set $BottomWs{q} {k+1+q}:{q}
workspace $ws1 output $primary
# ... and so on
workspace $ws{n} output $primary
workspace $TopWs1 output $top
# ... and so on
workspace $TopWs{k} output $top
workspace $BottomWs1 output $bottom
# ... and so on
workspace $BottomWs{q} output $bottom
# Switch to workspace
bindsym $mod+1 exec --no-startup-id pi3-smart-workspace -i 1
bindsym $mod+2 exec --no-startup-id pi3-smart-workspace -i 2
bindsym $mod+3 exec --no-startup-id pi3-smart-workspace -i 3
bindsym $mod+4 exec --no-startup-id pi3-smart-workspace -i 4
bindsym $mod+5 exec --no-startup-id pi3-smart-workspace -i 5
bindsym $mod+6 exec --no-startup-id pi3-smart-workspace -i 6
bindsym $mod+7 exec --no-startup-id pi3-smart-workspace -i 7
bindsym $mod+8 exec --no-startup-id pi3-smart-workspace -i 8
# Move focused container to workspace
bindsym $mod+Shift+1 exec --no-startup-id pi3-smart-workspace -i 1 -s
bindsym $mod+Shift+2 exec --no-startup-id pi3-smart-workspace -i 2 -s
bindsym $mod+Shift+3 exec --no-startup-id pi3-smart-workspace -i 3 -s
bindsym $mod+Shift+4 exec --no-startup-id pi3-smart-workspace -i 4 -s
bindsym $mod+Shift+5 exec --no-startup-id pi3-smart-workspace -i 5 -s
bindsym $mod+Shift+6 exec --no-startup-id pi3-smart-workspace -i 6 -s
bindsym $mod+Shift+7 exec --no-startup-id pi3-smart-workspace -i 7 -s
bindsym $mod+Shift+8 exec --no-startup-id pi3-smart-workspace -i 8 -s
# Move to workspace with focused container
bindsym $mod+Ctrl+1 exec --no-startup-id pi3-smart-workspace -i 1 -sk
bindsym $mod+Ctrl+2 exec --no-startup-id pi3-smart-workspace -i 2 -sk
bindsym $mod+Ctrl+3 exec --no-startup-id pi3-smart-workspace -i 3 -sk
bindsym $mod+Ctrl+4 exec --no-startup-id pi3-smart-workspace -i 4 -sk
bindsym $mod+Ctrl+5 exec --no-startup-id pi3-smart-workspace -i 5 -sk
bindsym $mod+Ctrl+6 exec --no-startup-id pi3-smart-workspace -i 6 -sk
bindsym $mod+Ctrl+7 exec --no-startup-id pi3-smart-workspace -i 7 -sk
bindsym $mod+Ctrl+8 exec --no-startup-id pi3-smart-workspace -i 8 -sk
```
## Development
```sh
pip install -e ".[dev]"
pytest # run the unit tests
python -m build # build sdist + wheel into dist/
twine check dist/* # validate the distributions
```
The unit tests cover the two pure functions (`parse_workspaces_per_output`
and `find_output_at_cursor`) and run without an X display, so they work in
CI. The i3-dispatch path needs a real multi-monitor session and is only
exercised manually.
## Credits
Thanks to Michał Wieluński for the inspiration
([pi3-switch](https://github.com/landmaj/pi3-switch)) and Tony Crisci for
the easy-to-use i3 Python library
([i3ipc-python](https://github.com/acrisci/i3ipc-python)).

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

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
packaging/aur/PKGBUILD Normal file
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"
}

View File

@@ -1,114 +1,640 @@
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
import i3ipc
class WorkSpacer:
DEFAULT_INIT_DIR = pathlib.Path("~/.config/i3/pi3-smart-workspace")
DEFAULT_WORKSPACES_PER_OUTPUT = 8
def __init__(self, args):
self.i3 = None
self.args = args
self.workspaces_on_outputs = {}
self.workspaces = None
self.outputs = None
self.config = None
self.mouse = pynput.mouse.Controller()
self.mouse_position = None
self.current_output_name = None
def _connect(self):
@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:
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)
sub_text = path.read_text()
except OSError:
continue
out.append(expand_i3_includes(sub_text, path.parent, _visited))
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)
last = match.end()
out.append(config_text[last:])
return "".join(out)
def main():
parser = argparse.ArgumentParser(
description="Dynamic changes the workspace, based on what output your cursoer is on."
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 InitArgs:
"""Parsed CLI arguments for the ``init`` subcommand."""
count: int
config_dir: pathlib.Path
dry_run: bool
force: bool
debug: bool
def _planned_files(
config_dir: pathlib.Path,
outputs: list[tuple[str, Rect]],
count: int,
) -> list[tuple[pathlib.Path, str]]:
"""Compute the (path, content) pairs that ``init`` would write.
Pure function: no filesystem or i3 access. Each output gets a folder
``<config_dir>/<output_name>/workspaces.conf``; the shared keybindings
go in ``<config_dir>/bindings.conf``. Output order is preserved from
``outputs`` so global workspace numbers are deterministic.
"""
files: list[tuple[pathlib.Path, str]] = [
(config_dir / "bindings.conf", render_bindings(count)),
]
for index, (output_name, _) in enumerate(outputs):
global_start = index * count + 1
files.append(
(
config_dir / output_name / "workspaces.conf",
render_workspaces_for_output(output_name, global_start, count),
)
)
return files
def run_init(args: InitArgs) -> None:
"""Generate per-output i3 workspace configs for the currently-active outputs.
Args:
args: Parsed init-subcommand arguments.
Raises:
RuntimeError: If no active outputs are detected, or if files
already exist and ``--force`` was not passed.
"""
i3 = i3ipc.Connection()
outputs = _active_outputs(i3)
if not outputs:
raise RuntimeError("no active outputs detected by i3")
config_dir = args.config_dir.expanduser()
files = _planned_files(config_dir, outputs, args.count)
if args.dry_run:
for path, content in files:
print(f"--- {path} ---")
print(content, end="")
return
existing = [path for path, _ in files if path.exists()]
if existing and not args.force:
listing = "\n ".join(str(p) for p in existing)
raise RuntimeError(
"refusing to overwrite existing files (pass --force to replace):\n "
+ listing
)
for path, content in files:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content)
print(f"wrote {path}")
print()
print("Add the following to your i3 config (typically ~/.config/i3/config):")
print(f" include {config_dir}/bindings.conf")
print(f" include {config_dir}/*/workspaces.conf")
print()
print("Then reload i3 with $mod+Shift+r.")
def _init_main(argv: list[str]) -> None:
parser = argparse.ArgumentParser(
prog="pi3-smart-workspace init",
description="Generate per-output i3 workspace configs and keybindings.",
)
parser.add_argument(
"-n", "--count", type=int, default=DEFAULT_WORKSPACES_PER_OUTPUT,
help=f"Workspaces per output (default: {DEFAULT_WORKSPACES_PER_OUTPUT}).",
)
parser.add_argument(
"--config-dir", type=pathlib.Path, default=DEFAULT_INIT_DIR,
help=f"Where to write generated configs (default: {DEFAULT_INIT_DIR}).",
)
parser.add_argument(
"--dry-run", action="store_true",
help="Print generated configs to stdout instead of writing them.",
)
parser.add_argument(
"--force", action="store_true",
help="Overwrite existing files in the config directory.",
)
parser.add_argument(
"-d", "--debug", action="store_true",
help="Re-raise exceptions instead of printing a friendly message.",
)
parsed = parser.parse_args(argv)
init_args = InitArgs(
count=parsed.count,
config_dir=parsed.config_dir,
dry_run=parsed.dry_run,
force=parsed.force,
debug=parsed.debug,
)
try:
run_init(init_args)
except Exception as exc:
if init_args.debug:
raise
print(f"pi3-smart-workspace init: {exc}", file=sys.stderr)
sys.exit(1)
# ---------------------------------------------------------------------------
# main entry point
# ---------------------------------------------------------------------------
def main() -> None:
"""Entry point for the ``pi3-smart-workspace`` console script.
If invoked as ``pi3-smart-workspace init [...]`` the call is routed to
:func:`_init_main`. Otherwise it parses ``sys.argv`` into an
:class:`Args` instance and dispatches to :func:`run`. Exceptions are
caught and printed as a single-line error on stderr (exit code 1),
unless ``--debug`` is set, in which case the traceback is re-raised.
"""
if len(sys.argv) > 1 and sys.argv[1] == "init":
_init_main(sys.argv[2:])
return
parser = argparse.ArgumentParser(
description="Switch (or shift) to the N-th workspace on the output your cursor is on."
)
parser.add_argument(
"-d", "--debug", action="store_true",
help="Print parsed state and re-raise exceptions.",
)
required = parser.add_argument_group("Required")
required.add_argument(
"-i", "--index", type=int, required=True,
help="The 1-based index into the workspaces declared for the cursor's output.",
)
shift_group = parser.add_argument_group("Shift", "manipulate the active window")
shift_group.add_argument(
"-s", "--shift", action="store_true",
help="Move the focused container to the indexed workspace.",
)
shift_group.add_argument(
"-k", "--keep-with-it", action="store_true",
help="With --shift, also follow the container to the new workspace.",
)
parsed = parser.parse_args()
args = Args(
index=parsed.index,
shift=parsed.shift,
keep_with_it=parsed.keep_with_it,
debug=parsed.debug,
)
try:
run(args)
except Exception as exc:
if args.debug:
raise
print(f"pi3-smart-workspace: {exc}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

57
pyproject.toml Normal file
View File

@@ -0,0 +1,57 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "pi3-smart-workspace"
version = "0.2.0"
description = "Switch i3 workspaces based on which output your mouse cursor is on."
readme = "README.md"
requires-python = ">=3.14"
license = "Apache-2.0"
authors = [
{ name = "Asger Geel Weirsøe", email = "asger@weircon.dk" },
]
keywords = ["i3", "i3wm", "workspace", "multi-monitor", "x11"]
classifiers = [
"Development Status :: 4 - Beta",
"Environment :: X11 Applications",
"Intended Audience :: End Users/Desktop",
"License :: OSI Approved :: Apache Software License",
"Operating System :: POSIX :: Linux",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.14",
"Topic :: Desktop Environment :: Window Managers",
]
dependencies = [
"i3ipc>=2.2.1",
"pynput>=1.7.6",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"build>=1.2",
"twine>=5.0",
]
[project.scripts]
pi3-smart-workspace = "pi3.smart_workspace:main"
[project.urls]
Homepage = "https://gitea.weircon.dk/agw/pi3-smart-workspace"
Issues = "https://gitea.weircon.dk/agw/pi3-smart-workspace/issues"
[tool.hatch.build.targets.wheel]
packages = ["pi3"]
[tool.hatch.build.targets.sdist]
include = [
"/pi3",
"/tests",
"/README.md",
"/LICENSE",
]
[tool.pytest.ini_options]
testpaths = ["tests"]

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

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",
],
)

0
tests/__init__.py Normal file
View File

View File

@@ -0,0 +1,400 @@
from textwrap import dedent
from pi3.smart_workspace import (
Rect,
_planned_files,
expand_i3_includes,
find_output_at_cursor,
parse_workspaces_per_output,
render_bindings,
render_workspaces_for_output,
sanitize_var_suffix,
)
# ---------------------------------------------------------------------------
# parse_workspaces_per_output
# ---------------------------------------------------------------------------
def test_parses_readme_example():
"""The full README.md example config round-trips through the parser."""
config = dedent("""
# Displays
set $primary eDP
set $top HDMI-A-0
set $bottom HDMI2
# Workspaces
set $ws1 1:1
set $ws2 2:2
set $TopWs1 3:1
set $TopWs2 4:2
set $BottomWs1 5:1
workspace $ws1 output $primary
workspace $ws2 output $primary
workspace $TopWs1 output $top
workspace $TopWs2 output $top
workspace $BottomWs1 output $bottom
""")
result = parse_workspaces_per_output(config, ["eDP", "HDMI-A-0", "HDMI2"])
assert result == {
"eDP": ["1:1", "2:2"],
"HDMI-A-0": ["3:1", "4:2"],
"HDMI2": ["5:1"],
}
def test_inactive_output_is_skipped():
config = dedent("""
set $primary eDP
set $external HDMI2
set $ws1 1:1
set $extWs1 9:ext
workspace $ws1 output $primary
workspace $extWs1 output $external
""")
# HDMI2 is unplugged
result = parse_workspaces_per_output(config, ["eDP"])
assert result == {"eDP": ["1:1"]}
def test_declaration_order_is_preserved():
config = dedent("""
set $out eDP
set $a 3:c
set $b 1:a
set $c 2:b
workspace $b output $out
workspace $c output $out
workspace $a output $out
""")
result = parse_workspaces_per_output(config, ["eDP"])
assert result == {"eDP": ["1:a", "2:b", "3:c"]}
def test_unrelated_set_lines_are_ignored():
config = dedent("""
set $primary eDP
set $ws1 1:1
set $term i3-sensible-terminal
set $launcher dmenu_run
set $mod Mod4
workspace $ws1 output $primary
""")
result = parse_workspaces_per_output(config, ["eDP"])
assert result == {"eDP": ["1:1"]}
def test_empty_active_outputs_returns_empty():
config = "set $primary eDP\nset $ws1 1:1\nworkspace $ws1 output $primary\n"
assert parse_workspaces_per_output(config, []) == {}
def test_binding_with_unknown_var_is_skipped():
config = dedent("""
set $primary eDP
set $ws1 1:1
workspace $ws1 output $unknownOutput
workspace $unknownWs output $primary
""")
assert parse_workspaces_per_output(config, ["eDP"]) == {}
def test_output_name_with_special_regex_chars_is_escaped():
# DisplayPort outputs often look like DP-1, HDMI-A-0 — regex-sensitive
config = dedent("""
set $main DP-1
set $ws1 1:1
workspace $ws1 output $main
""")
assert parse_workspaces_per_output(config, ["DP-1"]) == {"DP-1": ["1:1"]}
# ---------------------------------------------------------------------------
# find_output_at_cursor
# ---------------------------------------------------------------------------
def _outputs(*specs):
return [(name, Rect(*rect)) for name, rect in specs]
def test_single_output_contains_origin():
outputs = _outputs(("eDP", (0, 0, 1920, 1080)))
assert find_output_at_cursor(outputs, (0, 0)) == "eDP"
assert find_output_at_cursor(outputs, (960, 540)) == "eDP"
assert find_output_at_cursor(outputs, (1920, 1080)) == "eDP"
def test_cursor_outside_all_outputs_returns_none():
outputs = _outputs(("eDP", (0, 0, 1920, 1080)))
assert find_output_at_cursor(outputs, (3000, 2000)) is None
def test_side_by_side_outputs_shared_vertical_edge_belongs_to_left():
# Primary at origin, secondary directly to the right of it.
outputs = _outputs(
("eDP", (0, 0, 1920, 1080)),
("HDMI", (1920, 0, 1920, 1080)),
)
# On the shared edge x=1920 → belongs to the LEFT output.
assert find_output_at_cursor(outputs, (1920, 500)) == "eDP"
# Just past it → right output.
assert find_output_at_cursor(outputs, (1921, 500)) == "HDMI"
# Deep inside right output.
assert find_output_at_cursor(outputs, (3000, 500)) == "HDMI"
def test_stacked_outputs_shared_horizontal_edge_belongs_to_top():
outputs = _outputs(
("eDP", (0, 0, 1920, 1080)),
("HDMI", (0, 1080, 1920, 1080)),
)
# On shared edge y=1080 → top output.
assert find_output_at_cursor(outputs, (500, 1080)) == "eDP"
# Just past it → bottom output.
assert find_output_at_cursor(outputs, (500, 1081)) == "HDMI"
def test_iteration_order_does_not_change_edge_attribution():
# Same as side-by-side, but with outputs declared in opposite order.
outputs = _outputs(
("HDMI", (1920, 0, 1920, 1080)),
("eDP", (0, 0, 1920, 1080)),
)
assert find_output_at_cursor(outputs, (1920, 500)) == "eDP"
# ---------------------------------------------------------------------------
# Quoted workspace values (e.g. set $ws1 "1")
# ---------------------------------------------------------------------------
def test_parses_quoted_workspace_values():
"""i3 configs commonly use 'set $ws1 "1"' — the parser must strip quotes."""
config = dedent('''
set $primary eDP
set $ws1 "1"
set $ws2 "2:web"
workspace $ws1 output $primary
workspace $ws2 output $primary
''')
assert parse_workspaces_per_output(config, ["eDP"]) == {"eDP": ["1", "2:web"]}
def test_quoted_and_unquoted_can_mix():
config = dedent('''
set $primary eDP
set $ws1 "1"
set $ws2 2:b
workspace $ws1 output $primary
workspace $ws2 output $primary
''')
assert parse_workspaces_per_output(config, ["eDP"]) == {"eDP": ["1", "2:b"]}
# ---------------------------------------------------------------------------
# init rendering
# ---------------------------------------------------------------------------
def test_sanitize_var_suffix_replaces_non_alnum():
assert sanitize_var_suffix("eDP-1") == "eDP_1"
assert sanitize_var_suffix("HDMI-A-0") == "HDMI_A_0"
assert sanitize_var_suffix("DVI-I-2-2") == "DVI_I_2_2"
assert sanitize_var_suffix("plain") == "plain"
def test_render_workspaces_for_output_uses_global_offset():
out = render_workspaces_for_output("HDMI-A-0", global_start=5, count=3)
# Global numbers continue from 5; local labels reset to 1.
assert "set $ws_HDMI_A_0_1 5:1" in out
assert "set $ws_HDMI_A_0_2 6:2" in out
assert "set $ws_HDMI_A_0_3 7:3" in out
# Output alias is present and uses the sanitized var name.
assert "set $out_HDMI_A_0 HDMI-A-0" in out
# All three workspaces are bound to the output.
assert out.count("output $out_HDMI_A_0") == 3
def test_render_workspaces_rejects_bad_args():
import pytest
with pytest.raises(ValueError):
render_workspaces_for_output("eDP-1", global_start=0, count=8)
with pytest.raises(ValueError):
render_workspaces_for_output("eDP-1", global_start=1, count=0)
def test_render_bindings_emits_three_blocks():
out = render_bindings(count=2)
assert "bindsym $mod+1 exec --no-startup-id pi3-smart-workspace -i 1" in out
assert "bindsym $mod+2 exec --no-startup-id pi3-smart-workspace -i 2" in out
assert "bindsym $mod+Shift+1 exec --no-startup-id pi3-smart-workspace -i 1 -s" in out
assert "bindsym $mod+Ctrl+2 exec --no-startup-id pi3-smart-workspace -i 2 -s -k" in out
def test_render_bindings_honors_custom_mod():
out = render_bindings(count=1, mod="$myMod")
assert "$myMod+1" in out
assert "$mod+1" not in out
def test_planned_files_assigns_contiguous_global_ranges():
from pathlib import Path
outputs = [
("eDP-1", Rect(0, 0, 1920, 1080)),
("HDMI-A-0", Rect(1920, 0, 1920, 1080)),
("DP-1", Rect(3840, 0, 1920, 1080)),
]
files = _planned_files(Path("/tmp/x"), outputs, count=3)
# First file is the shared bindings.conf, then one workspaces.conf per output.
paths = [p for p, _ in files]
assert paths == [
Path("/tmp/x/bindings.conf"),
Path("/tmp/x/eDP-1/workspaces.conf"),
Path("/tmp/x/HDMI-A-0/workspaces.conf"),
Path("/tmp/x/DP-1/workspaces.conf"),
]
edp = files[1][1]
hdmi = files[2][1]
dp = files[3][1]
assert "set $ws_eDP_1_1 1:1" in edp
assert "set $ws_HDMI_A_0_1 4:1" in hdmi # 3 (count) + 1
assert "set $ws_DP_1_1 7:1" in dp # 6 + 1
# ---------------------------------------------------------------------------
# expand_i3_includes
# ---------------------------------------------------------------------------
def test_expand_includes_inlines_absolute_path(tmp_path):
inc = tmp_path / "inc.conf"
inc.write_text("set $foo bar\n")
main = f"# top\ninclude {inc}\n# tail\n"
result = expand_i3_includes(main, tmp_path)
assert "set $foo bar" in result
assert "include " not in result # directive consumed
assert "# top" in result and "# tail" in result
def test_expand_includes_handles_glob_in_alphabetical_order(tmp_path):
(tmp_path / "20-b.conf").write_text("# B\n")
(tmp_path / "10-a.conf").write_text("# A\n")
(tmp_path / "30-c.conf").write_text("# C\n")
main = f"include {tmp_path}/*.conf\n"
result = expand_i3_includes(main, tmp_path)
# Glob results are sorted, so A appears before B which appears before C.
assert result.index("# A") < result.index("# B") < result.index("# C")
def test_expand_includes_resolves_relative_paths_against_base_dir(tmp_path):
(tmp_path / "sub.conf").write_text("set $rel ok\n")
main = "include sub.conf\n"
result = expand_i3_includes(main, tmp_path)
assert "set $rel ok" in result
def test_expand_includes_recurses_through_chained_includes(tmp_path):
(tmp_path / "a.conf").write_text(f"include {tmp_path}/b.conf\n")
(tmp_path / "b.conf").write_text("set $deep yes\n")
main = f"include {tmp_path}/a.conf\n"
result = expand_i3_includes(main, tmp_path)
assert "set $deep yes" in result
def test_expand_includes_breaks_self_referential_cycle(tmp_path):
a = tmp_path / "a.conf"
a.write_text(f"set $marker present\ninclude {a}\n")
main = f"include {a}\n"
# Without cycle protection this would recurse forever.
result = expand_i3_includes(main, tmp_path)
assert result.count("set $marker present") == 1
def test_expand_includes_tolerates_missing_files(tmp_path):
main = f"set $before yes\ninclude {tmp_path}/does-not-exist.conf\nset $after yes\n"
# Glob simply yields nothing — must not raise.
result = expand_i3_includes(main, tmp_path)
assert "set $before yes" in result and "set $after yes" in result
def test_expand_then_parse_finds_workspaces_from_included_files(tmp_path):
"""The end-to-end fix: workspaces declared in an included file must
end up in parse_workspaces_per_output's output."""
(tmp_path / "out.conf").write_text(
"set $primary eDP\n"
"set $ws1 1:1\n"
"set $ws2 2:2\n"
"workspace $ws1 output $primary\n"
"workspace $ws2 output $primary\n"
)
main = f"set $mod Mod4\ninclude {tmp_path}/out.conf\n"
expanded = expand_i3_includes(main, tmp_path)
result = parse_workspaces_per_output(expanded, ["eDP"])
assert result == {"eDP": ["1:1", "2:2"]}
def test_init_output_parses_back_through_parser():
"""Round-trip: what `init` generates is parseable by `parse_workspaces_per_output`."""
from pathlib import Path
outputs = [
("eDP-1", Rect(0, 0, 1920, 1080)),
("HDMI-A-0", Rect(1920, 0, 1920, 1080)),
]
files = _planned_files(Path("/anywhere"), outputs, count=3)
# Concatenate everything except bindings.conf (parser only cares about
# the workspace/output declarations).
combined = "\n".join(content for _, content in files[1:])
result = parse_workspaces_per_output(combined, ["eDP-1", "HDMI-A-0"])
assert result == {
"eDP-1": ["1:1", "2:2", "3:3"],
"HDMI-A-0": ["4:1", "5:2", "6:3"],
}