diff options
| -rw-r--r-- | .chezmoiignore | 2 | ||||
| -rw-r--r-- | KEYBINDS.md | 93 | ||||
| -rw-r--r-- | docs/canonical-laptop.md | 8 | ||||
| -rw-r--r-- | dot_local/lib/dotfiles/canonical_desktop.py | 70 | ||||
| -rw-r--r-- | dot_local/share/gnome-shell/extensions/workspace-cycle@dotfiles/extension.js | 35 | ||||
| -rw-r--r-- | dot_local/share/gnome-shell/extensions/workspace-cycle@dotfiles/metadata.json | 8 | ||||
| -rw-r--r-- | dot_local/share/gnome-shell/extensions/workspace-cycle@dotfiles/schemas/org.gnome.shell.extensions.workspace-cycle.gschema.xml | 13 | ||||
| -rw-r--r-- | nix/flake.nix | 1 | ||||
| -rw-r--r-- | tests/fixtures/workspace-cycle.js | 54 | ||||
| -rw-r--r-- | tests/test_canonical.py | 1 | ||||
| -rw-r--r-- | tests/test_canonical_desktop.py | 30 | ||||
| -rw-r--r-- | tests/test_workspace_cycle.py | 38 |
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, + ) |
