Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa1de7fe16 | ||
|
|
8bbf7b8fbe | ||
|
|
f68b70c1da | ||
|
|
f3aa794847 | ||
|
|
289e2e8465 | ||
|
|
52feda8c10 | ||
|
|
0687a4ab31 | ||
|
|
2ab92a9149 |
@@ -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
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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/*
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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()
|
||||
@@ -0,0 +1,23 @@
|
||||
# Maintainer: Asger Geel Weirsoe <asger@weircon.dk>
|
||||
pkgname=pi3-smart-workspace
|
||||
pkgver=0.0.0
|
||||
pkgrel=1
|
||||
pkgdesc="Switch i3 workspaces based on which output your mouse cursor is on"
|
||||
arch=('any')
|
||||
url="https://gitea.weircon.dk/agw/pi3-smart-workspace"
|
||||
license=('Apache-2.0')
|
||||
depends=('python' 'i3-wm' 'python-i3ipc' 'python-pynput')
|
||||
makedepends=('python-build' 'python-installer' 'python-hatchling' 'python-wheel')
|
||||
source=("$pkgname-$pkgver.tar.gz::$url/archive/v$pkgver.tar.gz")
|
||||
sha256sums=('SKIP')
|
||||
|
||||
build() {
|
||||
cd "$srcdir/$pkgname"
|
||||
python -m build --wheel --no-isolation
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "$srcdir/$pkgname"
|
||||
python -m installer --destdir="$pkgdir" dist/*.whl
|
||||
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||
}
|
||||
+860
-122
File diff suppressed because it is too large
Load Diff
@@ -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"]
|
||||
@@ -1,5 +0,0 @@
|
||||
evdev==1.3.0
|
||||
i3ipc==2.2.1
|
||||
pynput==1.7.1
|
||||
python-xlib==0.27
|
||||
six==1.15.0
|
||||
@@ -1,32 +0,0 @@
|
||||
from setuptools import setup
|
||||
with open('README.rst', 'r') as fh:
|
||||
long_description = fh.read()
|
||||
|
||||
setup(
|
||||
name='pi3-smart-workspace',
|
||||
version='0.1.8',
|
||||
packages=['pi3'],
|
||||
url='https://github.com/GeneralDenmark/PyOutputHandler',
|
||||
license='Apache-2.0 License ',
|
||||
install_requires=[
|
||||
"evdev==1.3.0",
|
||||
"i3ipc==2.2.1",
|
||||
"pynput==1.7.1",
|
||||
"python-xlib==0.27",
|
||||
"six==1.15.0"
|
||||
],
|
||||
entry_points={"console_scripts": ["pi3-smart-workspace=pi3.smart_workspace:main"]},
|
||||
scripts=["pi3/smart_workspace.py"],
|
||||
long_description=long_description,
|
||||
long_description_content_type='text/x-rst',
|
||||
author='Asger Geel Weirsøe',
|
||||
author_email='asger@weirsoe.dk',
|
||||
description='Simple program that looks through the i3 config and finds the bound workspaces for each output, and then opening that workspace on the output, that the mouse is currently on.',
|
||||
classifiers=[
|
||||
"Development Status :: 3 - Alpha",
|
||||
"License :: OSI Approved :: Apache Software License",
|
||||
"Operating System :: POSIX :: Linux",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Topic :: Desktop Environment :: Window Managers",
|
||||
],
|
||||
)
|
||||
@@ -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()) == []
|
||||
Reference in New Issue
Block a user