From 0d8f843814a80ffc0ac4c8082d19cd0a895c7924 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Asger=20Weirs=C3=B8e?= Date: Tue, 19 May 2026 15:59:28 +0200 Subject: [PATCH] Fixed some bugs, updated how packages are handeled. Prepared on AUR publishing --- .github/workflows/ci.yml | 25 + .github/workflows/python-publish.yml | 40 +- README.md | 194 +++++++ README.rst | 128 ----- build.py | 32 -- pi3/smart_workspace.py | 744 ++++++++++++++++++++++----- pyproject.toml | 57 ++ requirements.txt | 5 - setup.py | 32 -- tests/__init__.py | 0 tests/test_smart_workspace.py | 400 ++++++++++++++ 11 files changed, 1317 insertions(+), 340 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 README.md delete mode 100644 README.rst delete mode 100644 build.py create mode 100644 pyproject.toml delete mode 100644 requirements.txt delete mode 100644 setup.py create mode 100644 tests/__init__.py create mode 100644 tests/test_smart_workspace.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1320a16 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 7ab60c3..fb32096 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -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/* diff --git a/README.md b/README.md new file mode 100644 index 0000000..e1de2bd --- /dev/null +++ b/README.md @@ -0,0 +1,194 @@ +# pi3-smart-workspace + +A small CLI helper for the [i3 window manager](https://i3wm.org/). Bound to +`$mod+` 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 $ ` — output aliases +2. `set $ ` — workspace-name aliases (quoted or unquoted) +3. `workspace $ output $` — 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)). diff --git a/README.rst b/README.rst deleted file mode 100644 index a281377..0000000 --- a/README.rst +++ /dev/null @@ -1,128 +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-workspace [-h] [-d] -i INDEX [-s] [-k] - - Changes the workspace, based on what output your cursor is on. - - optional arguments: - -h, --help show this help message and exit - -d, --debug Turn on debug mode. - - Required: - - -i INDEX, --index INDEX - The indexed workspace for the output where the cursor is currently located - - Shift: - manipulate the active window - - -s, --shift Moves the active window to the index workspace - -k, --keep-with-it Moves the active window to the index workspace, and moves with it - - -Installation ------------- - -Install using pip (recommended): - -:: - - pip install pi3-smart-workspace - -Example config to be inserted into your i3 config. - -:: - - # 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 - - # Shift 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 - - -Future work ------------ -Here a few ideas on how to improve pi3-smart-workspace could be improved in the future. -If anyone wants to submit a pr that solves one of the problems stated below feel free to do so :) - - -- Save the outputs and the mapped outputs in a json file, instead of looking through the config every time a button is pressed. - This would greatly reduce the cost of running this program, if we could just look up the required value in the json instead of the whole i3 config. - - In order for this to be a thing, we need to transition away from looking at active display, have the user set a exec_always and out indexer in their i3 config. - -- - -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 diff --git a/build.py b/build.py deleted file mode 100644 index a4d8720..0000000 --- a/build.py +++ /dev/null @@ -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() diff --git a/pi3/smart_workspace.py b/pi3/smart_workspace.py index a34f76f..284cd36 100644 --- a/pi3/smart_workspace.py +++ b/pi3/smart_workspace.py @@ -1,140 +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 subprocess +import re +import sys +from dataclasses import dataclass +from typing import Iterable + +import i3ipc -def get_active_outputs(): - ps = subprocess.Popen(('xrandr', '--listmonitors'), stdout=subprocess.PIPE) - output = subprocess.check_output(('awk', "{print $4}"), stdin=ps.stdout) - ps.wait() - return [x for x in output.decode('UTF-8').split('\n') if x is not None and x != ''] +DEFAULT_INIT_DIR = pathlib.Path("~/.config/i3/pi3-smart-workspace") +DEFAULT_WORKSPACES_PER_OUTPUT = 8 -names_for_outputs = r'(' + '|'.join(get_active_outputs()) + ')' +@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 -class WorkSpacer: +@dataclass(frozen=True) +class Args: + """Parsed CLI arguments, as a typed alternative to ``argparse.Namespace``. - 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 + 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. + """ - def _connect(self): - self.print_if_debug('All available outputs on device') - self.print_if_debug(names_for_outputs) - 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-Z0-9]+) (' + names_for_outputs + ')', - self.config, re.MULTILINE), start=1 - ): - config_outputs[match.group(1)] = match.group(2) - self.print_if_debug('All outputs listed in the config, matched on available configs') - self.print_if_debug(config_outputs) - 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) - self.print_if_debug('All config_workspaces_names') - self.print_if_debug(config_workspace_names) - - for matchNum, match in enumerate( - re.finditer(r'workspace (\$.*) output (\$.*)', self.config, re.MULTILINE) - ): - if not config_outputs.keys().__contains__(match.group(2)): - continue # Not an active display, skip it - if not self.workspaces_on_outputs.keys().__contains__(config_outputs[match.group(2)]): - self.workspaces_on_outputs[config_outputs[match.group(2)]] = [] - self.workspaces_on_outputs[config_outputs[match.group(2)]].append( - config_workspace_names[match.group(1)]) - self.print_if_debug("All workspaces with outputs") - self.print_if_debug(self.workspaces_on_outputs) - - except Exception as exc: - if self.args.debug: - raise 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) + index: int + shift: bool + keep_with_it: bool + debug: bool -def main(): - parser = argparse.ArgumentParser( - description="Changes the workspace, based on what output your cursor is on." +_INCLUDE_RE = re.compile(r"^\s*include\s+(\S.*?)\s*$", re.MULTILINE) + + +def expand_i3_includes( + config_text: str, + base_dir: pathlib.Path, + _visited: set[pathlib.Path] | None = None, +) -> str: + """Inline all i3 ``include`` directives in ``config_text``, recursively. + + The i3 IPC ``get_config`` reply returns the raw contents of the loaded + config file, *not* the post-include-expanded view that i3 itself acts + on. Without this expansion, any workspace/output bindings stashed in + included files (which is exactly what ``pi3-smart-workspace init`` + generates) would be invisible to :func:`parse_workspaces_per_output`. + + The expansion mirrors i3's ``wordexp(3)`` semantics for the common + case: leading ``~`` is expanded to ``$HOME``, relative paths are + resolved against the directory of the file containing the include, + and shell-style globs (``*``, ``?``, ``[…]``) expand alphabetically. + Quoted paths and environment variables aren't handled. + + Files that fail to read (missing, permission denied) are silently + skipped — i3 itself tolerates that. Include cycles are broken by + tracking already-visited resolved paths. + + Args: + config_text: Raw i3 config text to expand. + base_dir: Directory used to resolve relative include paths. + _visited: Internal recursion guard. Do not pass. + + Returns: + ``config_text`` with every ``include`` line replaced by the + contents of the matched files (themselves recursively expanded). + Non-include text is preserved verbatim. + """ + if _visited is None: + _visited = set() + + out: list[str] = [] + last = 0 + for match in _INCLUDE_RE.finditer(config_text): + out.append(config_text[last : match.start()]) + + raw_path = os.path.expanduser(match.group(1)) + if not os.path.isabs(raw_path): + raw_path = str(base_dir / raw_path) + + for matched in sorted(glob.glob(raw_path)): + path = pathlib.Path(matched) + real = path.resolve() + if real in _visited: + continue + _visited.add(real) + try: + sub_text = path.read_text() + except OSError: + continue + out.append(expand_i3_includes(sub_text, path.parent, _visited)) + + last = match.end() + out.append(config_text[last:]) + return "".join(out) + + +def parse_workspaces_per_output( + config_text: str, active_outputs: Iterable[str] +) -> dict[str, list[str]]: + """Extract per-output workspace lists from an i3 config. + + The function expects the user's i3 config to follow the three-part + convention documented in ``README.md``:: + + set $myOutput HDMI-A-0 # output alias + set $ws1 1:web # workspace-name alias + workspace $ws1 output $myOutput # binding + + Three regexes are applied to ``config_text``: + + 1. ``set $ `` — 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 $ `` — workspace-name aliases. The value must + start with a digit, which avoids accidentally matching unrelated + ``set`` lines such as ``set $mod Mod4``. + 3. ``workspace $ output $`` — 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 indexed workspace for the output where the cursor is currently located") + 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="Moves the active window to the index workspace") - shift_group.add_argument('-k', '--keep-with-it', action='store_true', - help='Moves the active window to the index workspace, and moves with it') - # 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 + ``//workspaces.conf``; the shared keybindings + go in ``/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() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5297647 --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 57eb532..0000000 --- a/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -evdev==1.3.0 -i3ipc==2.2.1 -pynput==1.7.1 -python-xlib==0.27 -six==1.15.0 diff --git a/setup.py b/setup.py deleted file mode 100644 index 5759f9b..0000000 --- a/setup.py +++ /dev/null @@ -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.21', - 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", - ], -) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_smart_workspace.py b/tests/test_smart_workspace.py new file mode 100644 index 0000000..f02043b --- /dev/null +++ b/tests/test_smart_workspace.py @@ -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"], + }