summaryrefslogtreecommitdiffstatshomepage
diff options
context:
space:
mode:
authorsommerfeld <sommerfeld@sommerfeld.dev>2026-09-26 22:23:04 +0100
committersommerfeld <sommerfeld@sommerfeld.dev>2026-09-26 22:23:04 +0100
commit869ea8436d9cf9d5748e6c089cc0b0b58358e75f (patch)
tree57d765d0a975b67573673f13d00a1584cb2c35f4
parentd6f4aee0ccf47dea61204f4af44d8eed2e65f96c (diff)
downloaddotfiles-869ea8436d9cf9d5748e6c089cc0b0b58358e75f.tar.gz
dotfiles-869ea8436d9cf9d5748e6c089cc0b0b58358e75f.tar.bz2
dotfiles-869ea8436d9cf9d5748e6c089cc0b0b58358e75f.zip
Align GNOME shortcuts with the Sway setup
-rw-r--r--.chezmoiignore2
-rw-r--r--KEYBINDS.md93
-rw-r--r--docs/canonical-laptop.md8
-rw-r--r--dot_local/lib/dotfiles/canonical_desktop.py70
-rw-r--r--dot_local/share/gnome-shell/extensions/workspace-cycle@dotfiles/extension.js35
-rw-r--r--dot_local/share/gnome-shell/extensions/workspace-cycle@dotfiles/metadata.json8
-rw-r--r--dot_local/share/gnome-shell/extensions/workspace-cycle@dotfiles/schemas/org.gnome.shell.extensions.workspace-cycle.gschema.xml13
-rw-r--r--nix/flake.nix1
-rw-r--r--tests/fixtures/workspace-cycle.js54
-rw-r--r--tests/test_canonical.py1
-rw-r--r--tests/test_canonical_desktop.py30
-rw-r--r--tests/test_workspace_cycle.py38
12 files changed, 319 insertions, 34 deletions
diff --git a/.chezmoiignore b/.chezmoiignore
index a3bbf94..b91ca15 100644
--- a/.chezmoiignore
+++ b/.chezmoiignore
@@ -91,6 +91,7 @@ dot_config/nvim/nvim-pack-lock.json
.local/share/gnome-shell/extensions/*
!.local/share/gnome-shell/extensions/corporate-panel@dotfiles/**
!.local/share/gnome-shell/extensions/external-display@dotfiles/**
+!.local/share/gnome-shell/extensions/workspace-cycle@dotfiles/**
{{ range list "dictate" "ocr" "record" "rqr" "wqr" "linkhandler" }}
!.local/bin/{{ . }}
{{ end }}
@@ -105,5 +106,6 @@ canonical-nvim-lock.sh
.local/lib/dotfiles/
.local/share/gnome-shell/extensions/corporate-panel@dotfiles/
.local/share/gnome-shell/extensions/external-display@dotfiles/
+.local/share/gnome-shell/extensions/workspace-cycle@dotfiles/
.local/bin/canonical-desktop
{{ end }}
diff --git a/KEYBINDS.md b/KEYBINDS.md
index 2352729..85ba3d0 100644
--- a/KEYBINDS.md
+++ b/KEYBINDS.md
@@ -426,32 +426,81 @@ in `~/.XCompose`. Press and release Compose, then the sequence.
These keys apply only to the `canonical` role with O-Tiling. Company-locked
settings remain unchanged.
-| Keys | Action |
-| ------------------- | ---------------------------------------- |
-| Super+Return | Ghostty |
-| Super+Shift+Return | Yazi in Ghostty |
-| Super+Shift+B | Firefox |
-| Super+T | Thunderbird, normal window |
-| Super+Shift+Q | Close window |
-| Super+F | Fullscreen |
-| Super+Shift+Space | Toggle floating |
-| Super+Period | Emoji picker |
-| Super+H/J/K/Right | Focus left/down/up/right |
-| Super+Shift+H/J/K/L | Move window left/down/up/right |
-| Super+I | Toggle dictation; copy text to clipboard |
-| Super+Shift+O | NormCap OCR |
-| Super+Shift+R | Toggle portal recording |
-| Super+P | Copyous history |
-| Caps Lock | Escape |
-| Right Ctrl | Compose |
+| Keys | Action |
+| ---------------------------------- | ------------------------------------------ |
+| Super+Return | Ghostty |
+| Super+Shift+Return | Yazi in Ghostty |
+| Super+Shift+B | Firefox |
+| Super+T | Thunderbird, normal window |
+| Super+Shift+Q | Close window |
+| Super+F | Fullscreen |
+| Super+Shift+Space | Toggle floating |
+| Super+Period | Emoji picker |
+| Super+H/J/K/L | Focus left/down/up/right |
+| Super+Shift+H/J/K/L | Move window left/down/up/right |
+| Super+I | Toggle dictation; copy text to clipboard |
+| Super+Shift+O | NormCap OCR |
+| Super+Shift+R | Toggle portal recording |
+| Super+P | Copyous history |
+| Super+Tab / Super+Shift+Tab | Next / previous workspace, with wraparound |
+| Super+Shift+S | Lock screen |
+| Super+[ / Super+] | Previous / next window in GNOME order |
+| Super+S | Toggle O-Tiling stacking |
+| Super+E | Toggle split orientation |
+| Super+R | Enter O-Tiling management mode |
+| H/J/K/L in management mode | Resize left/down/up/right |
+| Shift+H/J/K/L in management mode | Move window |
+| Return / Escape in management mode | Accept / exit |
+| Super+C | IPython in Ghostty |
+| Super+Shift+T | Scratch editor in Ghostty |
+| Super+Ctrl+K / Super+Ctrl+J | Volume up / down |
+| Super+Shift+M | Mute output |
+| Super+M | Mute microphone |
+| Super+Ctrl+Space | Play / pause |
+| Super+Ctrl+L / Super+Ctrl+H | Next / previous track |
+| Super+Ctrl+] / Super+Ctrl+[ | Brightness up / down |
+| Super+Ctrl+N | Show notification history |
+| Super+Shift+E | GNOME logout dialog |
+| Print / Shift+Print | Screenshot selection / full screenshot |
+| Caps Lock | Escape |
+| Right Ctrl | Compose |
-Use GNOME's screenshot UI. O-Tiling's
-other default shortcuts remain active. Sway modes and recovery keys do not
-apply to this role.
-Super+L remains the GNOME lock shortcut. Super+D opens the application launcher.
+Super+D opens the application launcher. Alt+Tab still switches applications.
+Automatic screen locking and company policies are unchanged. The lock key does
+not pause media, unlike the Sway command. GNOME keeps its hardware media keys.
XF86Display remains the display-switching key; Super+P opens clipboard history.
Super+1 through Super+9 select workspaces 1 through 9; Super+0 selects workspace 10. Add Shift to move the current window to that workspace.
GNOME uses dynamic workspaces. Number shortcuts select existing workspaces;
they do not create missing ones. Empty workspaces are removed, so numbers can
change. GNOME keeps one empty workspace at the end for new windows.
+Workspace cycling includes that empty workspace. GNOME workspaces are not
+independent per output as they are in Sway.
+
+## Differences From Halley2
+
+The following Sway actions have no direct equivalent in this GNOME setup:
+
+| Halley2 keys | Difference on GNOME |
+| ---------------------------------------- | -------------------------------------------------------------------------------- |
+| Super+B/V | No forced horizontal/vertical next split; Super+E toggles the current split |
+| Super+W | No separate Sway tabbed layout; Super+S toggles O-Tiling stacking |
+| Super+Space / Super+A | No tiled/floating focus toggle or parent-container focus |
+| Super+Minus / Super+Shift+Minus | No scratchpad |
+| Super+T | Opens Thunderbird; does not hide it in a scratchpad |
+| Super+R, H/J/K/L | O-Tiling resize follows its grid, not Sway's 10-pixel increments |
+| Super+N / Super+Shift+N | No notification dismissal binding; use GNOME's notification list |
+| Super+Ctrl+N | Opens history; does not restore a dismissed notification |
+| Super+Shift+P | No direct Copyous deletion action; delete from its interface |
+| Super+Shift+C | No Sway reload; run `just canonical-desktop` |
+| Super+Shift+E | Logout dialog rather than the Sway power menu |
+| Super+Z / Super+X | No QR/system key modes; run `rqr`, `wqr`, or use GNOME controls |
+| Super+Shift+T / Super+C | Editor and calculator follow normal tiling rather than forcing a floating window |
+| XF86Tools / XF86Keyboard / XF86Favorites | GNOME hardware-key behavior, not the Sway helper commands |
+| Super+Ctrl+Shift+R | No Sway display recovery command |
+
+The screenshot selection uses GNOME's UI and save location. The bracket keys
+use GNOME's window order rather than Sway's container order. Input-source
+switching on Super+Space and Super+Shift+Space is disabled to avoid conflicts.
+Policy-locked or locally added shortcuts can still conflict; check the output
+of `just canonical-desktop` and test the keys after login.
diff --git a/docs/canonical-laptop.md b/docs/canonical-laptop.md
index 67442f4..3e93a56 100644
--- a/docs/canonical-laptop.md
+++ b/docs/canonical-laptop.md
@@ -321,6 +321,14 @@ The managed shortcuts are listed in `KEYBINDS.md`. Settings are reapplied by
`just apply` in GNOME. Policy-locked keys are reported and skipped. Only touched
keys are saved in `~/.local/state/dotfiles/gnome-settings.json`.
+Super+H/J/K/L focuses windows. Super+Shift+S locks the screen; Super+L no longer
+locks it. This changes the manual shortcut only, not automatic locking.
+Super+Tab and Super+Shift+Tab cycle workspaces with wraparound through the local
+Workspace Cycle extension. Log out and back in after its first deployment.
+GNOME's Alt+Tab application switcher remains available. Super+R enters
+O-Tiling management mode, where H/J/K/L resizes and Return or Escape exits.
+See the shortcut table for Sway actions that GNOME cannot reproduce directly.
+
```sh
just canonical-desktop-restore
```
diff --git a/dot_local/lib/dotfiles/canonical_desktop.py b/dot_local/lib/dotfiles/canonical_desktop.py
index 96e4e4e..b184ad0 100644
--- a/dot_local/lib/dotfiles/canonical_desktop.py
+++ b/dot_local/lib/dotfiles/canonical_desktop.py
@@ -4,12 +4,14 @@ import importlib
import json
import os
import shlex
+import subprocess
import sys
from pathlib import Path
HOME = Path.home()
STATE = HOME / ".local/state/dotfiles/gnome-settings.json"
EXTENSIONS = [
+ "workspace-cycle@dotfiles",
"external-display@dotfiles",
"o-tiling@oliwebd.github.com",
"copyous@boerdereinar.dev",
@@ -98,6 +100,7 @@ def shortcuts(saved: dict) -> None:
saved,
)
shell = shlex.quote(str(HOME / ".nix-profile/bin/zsh"))
+ scratch = shlex.quote('nvim "$XDG_RUNTIME_DIR/scratch-$(date +%s).txt"')
actions = {
"terminal": ("<Super>Return", "/snap/bin/ghostty"),
"files": ("<Super><Shift>Return", f"/snap/bin/ghostty -e {shell} -lc yazi"),
@@ -106,6 +109,11 @@ def shortcuts(saved: dict) -> None:
"dictate": ("<Super>i", f"{shell} -lc dictate"),
"ocr": ("<Super><Shift>o", f"{shell} -lc ocr"),
"record": ("<Super><Shift>r", f"{shell} -lc 'record toggle'"),
+ "calculator": ("<Super>c", f"/snap/bin/ghostty -e {shell} -lc ipython"),
+ "scratch-editor": (
+ "<Super><Shift>t",
+ f"/snap/bin/ghostty -e {shell} -lc {scratch}",
+ ),
"clipboard": (
"<Super>p",
"gdbus call --session --dest org.gnome.Shell.Extensions.Copyous --object-path /org/gnome/Shell/Extensions/Copyous --method org.gnome.Shell.Extensions.Copyous.Show",
@@ -156,6 +164,7 @@ def apply_settings(saved: dict) -> None:
write_key(
"org.gnome.desktop.wm.keybindings", "toggle-fullscreen", ["<Super>f"], saved
)
+ desktop_bindings(saved)
tiling(saved)
shortcuts(saved)
workspaces(saved)
@@ -163,6 +172,41 @@ def apply_settings(saved: dict) -> None:
extensions(saved)
+def desktop_bindings(saved: dict) -> None:
+ for key, bindings in {
+ "switch-applications": ["<Alt>Tab"],
+ "switch-applications-backward": ["<Alt><Shift>Tab"],
+ "switch-input-source": [],
+ "switch-input-source-backward": [],
+ "cycle-windows": ["<Super>bracketright", "<Alt>Escape"],
+ "cycle-windows-backward": ["<Super>bracketleft", "<Alt><Shift>Escape"],
+ }.items():
+ write_key("org.gnome.desktop.wm.keybindings", key, bindings, saved)
+ for key, bindings in {
+ "screensaver": ["<Super><Shift>s"],
+ "volume-up": ["<Super><Control>k"],
+ "volume-down": ["<Super><Control>j"],
+ "volume-mute": ["<Super><Shift>m"],
+ "mic-mute": ["<Super>m"],
+ "play": ["<Super><Control>space"],
+ "next": ["<Super><Control>l"],
+ "previous": ["<Super><Control>h"],
+ "logout": ["<Super><Shift>e", "<Control><Alt>Delete"],
+ }.items():
+ write_key("org.gnome.settings-daemon.plugins.media-keys", key, bindings, saved)
+ for key, bindings in {
+ "toggle-message-tray": ["<Super><Control>n"],
+ "show-screenshot-ui": ["Print"],
+ "screenshot": ["<Shift>Print"],
+ "screen-brightness-up": ["XF86MonBrightnessUp", "<Super><Control>bracketright"],
+ "screen-brightness-down": [
+ "XF86MonBrightnessDown",
+ "<Super><Control>bracketleft",
+ ],
+ }.items():
+ write_key("org.gnome.shell.keybindings", key, bindings, saved)
+
+
def tiling(saved: dict) -> None:
schema = "org.gnome.shell.extensions.o-tiling"
for key, value in {
@@ -174,22 +218,25 @@ def tiling(saved: dict) -> None:
"panel-transparency": False,
"mouse-cursor-follows-active-window": False,
"toggle-floating": ["<Super><Shift>space"],
+ "toggle-stacking-global": ["<Super>s"],
+ "tile-orientation": ["<Super>e"],
+ "tile-enter": ["<Super>r"],
}.items():
write_key(schema, key, value, saved)
for key in [
- "tile-enter",
"toggle-tiling",
- "tile-orientation",
"pop-workspace-up",
"pop-workspace-down",
]:
write_key(schema, key, [], saved)
for direction, letter in zip(["left", "down", "up", "right"], "hjkl"):
- binding = "<Super>Right" if direction == "right" else f"<Super>{letter}"
- write_key(schema, f"focus-{direction}", [binding], saved)
+ write_key(schema, f"focus-{direction}", [f"<Super>{letter}"], saved)
write_key(
schema, f"tile-move-{direction}-global", [f"<Super><Shift>{letter}"], saved
)
+ write_key(schema, f"tile-resize-{direction}", [letter], saved)
+ write_key(schema, f"tile-move-{direction}", [f"<Shift>{letter}"], saved)
+ write_key(schema, f"tile-swap-{direction}", [], saved)
def panel(saved: dict) -> None:
@@ -221,6 +268,11 @@ def panel(saved: dict) -> None:
def workspaces(saved: dict) -> None:
write_key("org.gnome.mutter", "dynamic-workspaces", True, saved)
+ for key, binding in [
+ ("next-workspace", "<Super>Tab"),
+ ("previous-workspace", "<Super><Shift>Tab"),
+ ]:
+ write_key("org.gnome.shell.extensions.workspace-cycle", key, [binding], saved)
for number in range(1, 11):
key = str(number % 10)
for action, modifier in [("switch", ""), ("move", "<Shift>")]:
@@ -266,6 +318,16 @@ def main() -> None:
raise SystemExit("Run this command from the GNOME desktop session.")
saved = json.loads(STATE.read_text()) if STATE.exists() else {}
if sys.argv[1:] == ["settings"]:
+ subprocess.run(
+ [
+ "/usr/bin/glib-compile-schemas",
+ str(
+ HOME
+ / ".local/share/gnome-shell/extensions/workspace-cycle@dotfiles/schemas"
+ ),
+ ],
+ check=True,
+ )
apply_settings(saved)
elif sys.argv[1:] == ["restore"]:
restore(saved)
diff --git a/dot_local/share/gnome-shell/extensions/workspace-cycle@dotfiles/extension.js b/dot_local/share/gnome-shell/extensions/workspace-cycle@dotfiles/extension.js
new file mode 100644
index 0000000..dc6e3c9
--- /dev/null
+++ b/dot_local/share/gnome-shell/extensions/workspace-cycle@dotfiles/extension.js
@@ -0,0 +1,35 @@
+import Meta from "gi://Meta";
+import Shell from "gi://Shell";
+import { Extension } from "resource:///org/gnome/shell/extensions/extension.js";
+import * as Main from "resource:///org/gnome/shell/ui/main.js";
+
+export default class WorkspaceCycle extends Extension {
+ enable() {
+ const settings = this.getSettings();
+ for (const [name, step] of [
+ ["next-workspace", 1],
+ ["previous-workspace", -1],
+ ]) {
+ Main.wm.addKeybinding(
+ name,
+ settings,
+ Meta.KeyBindingFlags.NONE,
+ Shell.ActionMode.NORMAL | Shell.ActionMode.OVERVIEW,
+ () => this.cycle(step),
+ );
+ }
+ }
+
+ cycle(step) {
+ const manager = global.workspace_manager;
+ const count = manager.n_workspaces;
+ if (count < 2) return;
+ const index = (manager.get_active_workspace_index() + step + count) % count;
+ manager.get_workspace_by_index(index).activate(global.get_current_time());
+ }
+
+ disable() {
+ Main.wm.removeKeybinding("next-workspace");
+ Main.wm.removeKeybinding("previous-workspace");
+ }
+}
diff --git a/dot_local/share/gnome-shell/extensions/workspace-cycle@dotfiles/metadata.json b/dot_local/share/gnome-shell/extensions/workspace-cycle@dotfiles/metadata.json
new file mode 100644
index 0000000..8f8eafd
--- /dev/null
+++ b/dot_local/share/gnome-shell/extensions/workspace-cycle@dotfiles/metadata.json
@@ -0,0 +1,8 @@
+{
+ "uuid": "workspace-cycle@dotfiles",
+ "name": "Workspace Cycle",
+ "description": "Cycle through workspaces in both directions.",
+ "shell-version": ["50"],
+ "settings-schema": "org.gnome.shell.extensions.workspace-cycle",
+ "version": 1
+}
diff --git a/dot_local/share/gnome-shell/extensions/workspace-cycle@dotfiles/schemas/org.gnome.shell.extensions.workspace-cycle.gschema.xml b/dot_local/share/gnome-shell/extensions/workspace-cycle@dotfiles/schemas/org.gnome.shell.extensions.workspace-cycle.gschema.xml
new file mode 100644
index 0000000..25a993f
--- /dev/null
+++ b/dot_local/share/gnome-shell/extensions/workspace-cycle@dotfiles/schemas/org.gnome.shell.extensions.workspace-cycle.gschema.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<schemalist>
+ <schema id="org.gnome.shell.extensions.workspace-cycle" path="/org/gnome/shell/extensions/workspace-cycle/">
+ <key name="next-workspace" type="as">
+ <default>[]</default>
+ <summary>Next workspace, with wraparound</summary>
+ </key>
+ <key name="previous-workspace" type="as">
+ <default>[]</default>
+ <summary>Previous workspace, with wraparound</summary>
+ </key>
+ </schema>
+</schemalist>
diff --git a/nix/flake.nix b/nix/flake.nix
index dd62a11..84b4d51 100644
--- a/nix/flake.nix
+++ b/nix/flake.nix
@@ -287,6 +287,7 @@
nixfmt
nodejs
networkmanager
+ glib
prettier
ruff
selene
diff --git a/tests/fixtures/workspace-cycle.js b/tests/fixtures/workspace-cycle.js
new file mode 100644
index 0000000..13e1bc7
--- /dev/null
+++ b/tests/fixtures/workspace-cycle.js
@@ -0,0 +1,54 @@
+import assert from "node:assert/strict";
+
+const bindings = new Map();
+const Extension = class {
+ getSettings() {
+ return {};
+ }
+};
+const Meta = { KeyBindingFlags: { NONE: 0 } };
+const Shell = { ActionMode: { NORMAL: 1, OVERVIEW: 2 } };
+const Main = {
+ wm: {
+ addKeybinding(name, settings, flags, mode, callback) {
+ assert.equal(mode, 3);
+ bindings.set(name, callback);
+ },
+ removeKeybinding(name) {
+ bindings.delete(name);
+ },
+ },
+};
+let active = 0;
+let activations = 0;
+global.workspace_manager = {
+ n_workspaces: 3,
+ get_active_workspace_index: () => active,
+ get_workspace_by_index: (index) => ({
+ activate(time) {
+ assert.equal(time, 123);
+ active = index;
+ activations++;
+ },
+ }),
+};
+global.get_current_time = () => 123;
+
+// EXTENSION
+
+const extension = new WorkspaceCycle();
+extension.enable();
+bindings.get("previous-workspace")();
+assert.equal(active, 2);
+bindings.get("next-workspace")();
+assert.equal(active, 0);
+bindings.get("next-workspace")();
+assert.equal(active, 1);
+global.workspace_manager.n_workspaces = 1;
+extension.cycle(1);
+assert.equal(activations, 3);
+extension.disable();
+assert.equal(bindings.size, 0);
+extension.enable();
+assert.equal(bindings.size, 2);
+extension.disable();
diff --git a/tests/test_canonical.py b/tests/test_canonical.py
index 724b922..317fe98 100644
--- a/tests/test_canonical.py
+++ b/tests/test_canonical.py
@@ -224,6 +224,7 @@ class RoleTests(unittest.TestCase):
".local/bin/rqr",
".local/share/gnome-shell/extensions/corporate-panel@dotfiles/extension.js",
".local/share/gnome-shell/extensions/external-display@dotfiles/extension.js",
+ ".local/share/gnome-shell/extensions/workspace-cycle@dotfiles/extension.js",
]:
self.assertIn(required, files)
for path in files:
diff --git a/tests/test_canonical_desktop.py b/tests/test_canonical_desktop.py
index 146ae9f..039948f 100644
--- a/tests/test_canonical_desktop.py
+++ b/tests/test_canonical_desktop.py
@@ -60,17 +60,31 @@ class DesktopTests(unittest.TestCase):
with patch.object(desktop, "write_key") as write:
desktop.tiling({})
schema = "org.gnome.shell.extensions.o-tiling"
- for key in [
- "tile-enter",
- "toggle-tiling",
- "tile-orientation",
- "pop-workspace-up",
- "pop-workspace-down",
- ]:
+ for key in ["toggle-tiling", "pop-workspace-up", "pop-workspace-down"]:
write.assert_any_call(schema, key, [], {})
write.assert_any_call(schema, "new-window-placement", "focused", {})
- write.assert_any_call(schema, "focus-right", ["<Super>Right"], {})
+ write.assert_any_call(schema, "focus-right", ["<Super>l"], {})
write.assert_any_call(schema, "tile-move-down-global", ["<Super><Shift>j"], {})
+ write.assert_any_call(schema, "tile-enter", ["<Super>r"], {})
+ write.assert_any_call(schema, "tile-resize-left", ["h"], {})
+
+ def test_desktop_bindings_release_sway_keys_and_keep_lock(self):
+ with patch.object(desktop, "write_key") as write:
+ desktop.desktop_bindings({})
+ wm = "org.gnome.desktop.wm.keybindings"
+ media = "org.gnome.settings-daemon.plugins.media-keys"
+ write.assert_any_call(wm, "switch-applications", ["<Alt>Tab"], {})
+ write.assert_any_call(wm, "switch-input-source", [], {})
+ write.assert_any_call(wm, "switch-input-source-backward", [], {})
+ write.assert_any_call(media, "screensaver", ["<Super><Shift>s"], {})
+ write.assert_any_call(media, "mic-mute", ["<Super>m"], {})
+
+ def test_workspace_cycle_keys_are_configured(self):
+ with patch.object(desktop, "write_key") as write:
+ desktop.workspaces({})
+ schema = "org.gnome.shell.extensions.workspace-cycle"
+ write.assert_any_call(schema, "next-workspace", ["<Super>Tab"], {})
+ write.assert_any_call(schema, "previous-workspace", ["<Super><Shift>Tab"], {})
def test_clipboard_shortcut_preserves_other_display_bindings(self):
settings = MagicMock()
diff --git a/tests/test_workspace_cycle.py b/tests/test_workspace_cycle.py
new file mode 100644
index 0000000..09dbc2a
--- /dev/null
+++ b/tests/test_workspace_cycle.py
@@ -0,0 +1,38 @@
+import json
+import subprocess
+import unittest
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+EXTENSION = ROOT / "dot_local/share/gnome-shell/extensions/workspace-cycle@dotfiles"
+
+
+class WorkspaceCycleTests(unittest.TestCase):
+ def test_schema_and_metadata(self):
+ metadata = json.loads((EXTENSION / "metadata.json").read_text())
+ self.assertEqual(metadata["uuid"], "workspace-cycle@dotfiles")
+ self.assertEqual(metadata["shell-version"], ["50"])
+ subprocess.run(
+ [
+ "glib-compile-schemas",
+ "--strict",
+ "--dry-run",
+ str(EXTENSION / "schemas"),
+ ],
+ check=True,
+ )
+
+ def test_cycle_wraps_in_both_directions_and_releases_keys(self):
+ source = "\n".join(
+ line
+ for line in (EXTENSION / "extension.js").read_text().splitlines()
+ if not line.startswith("import ")
+ )
+ source = source.replace("export default class", "class")
+ harness = (ROOT / "tests/fixtures/workspace-cycle.js").read_text()
+ subprocess.run(
+ ["node", "--input-type=module"],
+ input=harness.replace("// EXTENSION", source),
+ text=True,
+ check=True,
+ )