├── .github └── ISSUE_TEMPLATE.md ├── .gitignore ├── CODE_OF_CONDUCT.md ├── LICENSE ├── Makefile ├── README.md ├── TESTING.md ├── dark.css ├── debian ├── changelog ├── control ├── copyright ├── install ├── rules └── source │ └── format ├── highcontrast.css ├── icons ├── pop-shell-auto-off-symbolic.svg └── pop-shell-auto-on-symbolic.svg ├── keybindings ├── 10-pop-shell-move.xml ├── 10-pop-shell-navigate.xml └── 10-pop-shell-tile.xml ├── light.css ├── metadata.json ├── package.json ├── schemas └── org.gnome.shell.extensions.pop-shell.gschema.xml ├── screenshot.webp ├── scripts ├── configure.sh └── transpile.sh ├── src ├── arena.ts ├── auto_tiler.ts ├── color_dialog │ ├── src │ │ ├── main.ts │ │ └── mod.d.ts │ └── tsconfig.json ├── config.ts ├── context.ts ├── dbus_service.ts ├── dialog_add_exception.ts ├── ecs.ts ├── error.ts ├── events.ts ├── executor.ts ├── extension.ts ├── floating_exceptions │ ├── src │ │ ├── config.ts │ │ ├── main.ts │ │ ├── mod.d.ts │ │ └── utils.ts │ └── tsconfig.json ├── focus.ts ├── forest.ts ├── fork.ts ├── geom.ts ├── grab_op.ts ├── keybindings.ts ├── launcher.ts ├── launcher_service.ts ├── lib.ts ├── log.ts ├── mod.d.ts ├── movement.ts ├── node.ts ├── once_cell.ts ├── panel_settings.ts ├── paths.ts ├── prefs.ts ├── rectangle.ts ├── result.ts ├── scheduler.ts ├── search.ts ├── settings.ts ├── shell.ts ├── shortcut_overlay.ts ├── stack.ts ├── tags.ts ├── tiling.ts ├── types.d.ts ├── utils.ts ├── window.ts └── xprop.ts └── tsconfig.json /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 4 | 5 | **(1) Issue/Bug Description:** 6 | 7 | 8 | 9 | **(2) Steps to reproduce (if you know):** 10 | 11 | 12 | 13 | **(3) Expected behavior:** 14 | 15 | 16 | 17 | **(4) Distribution (run `cat /etc/os-release`):** 18 | 19 | 20 | 21 | **(5) Gnome Shell version:** 22 | 23 | 24 | 25 | **(6) Pop Shell version (run `apt policy pop-shell` or provide the latest commit if building locally):** 26 | 29 | 30 | 31 | 32 | **(7) Where was Pop Shell installed from:** 33 | 34 | 35 | 36 | **(8) Monitor Setup (2 x 1080p, 4K, Primary(Horizontal), Secondary(Vertical), etc):** 37 | 38 | 39 | 40 | **(9) Other Installed/Enabled Extensions:** 41 | 42 | 43 | 44 | **(10) Other Notes:** 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.zip 2 | *.swp 3 | _build 4 | target 5 | schemas/gschemas.compiled 6 | debian/* 7 | !debian/source 8 | !debian/changelog 9 | !debian/control 10 | !debian/copyright 11 | !debian/rules 12 | !debian/*install 13 | .confirm_shortcut_change 14 | .vscode 15 | node_modules -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | Contributors to this repo agree to be bound by the [Pop! Code of Conduct](https://github.com/pop-os/code-of-conduct). 2 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Retrieve the UUID from ``metadata.json`` 2 | UUID = $(shell grep -E '^[ ]*"uuid":' ./metadata.json | sed 's@^[ ]*"uuid":[ ]*"\(.\+\)",[ ]*@\1@') 3 | VERSION = $(shell grep version tsconfig.json | awk -F\" '{print $$4}') 4 | 5 | ifeq ($(XDG_DATA_HOME),) 6 | XDG_DATA_HOME = $(HOME)/.local/share 7 | endif 8 | 9 | ifeq ($(strip $(DESTDIR)),) 10 | INSTALLBASE = $(XDG_DATA_HOME)/gnome-shell/extensions 11 | PLUGIN_BASE = $(XDG_DATA_HOME)/pop-shell/launcher 12 | SCRIPTS_BASE = $(XDG_DATA_HOME)/pop-shell/scripts 13 | else 14 | INSTALLBASE = $(DESTDIR)/usr/share/gnome-shell/extensions 15 | PLUGIN_BASE = $(DESTDIR)/usr/lib/pop-shell/launcher 16 | SCRIPTS_BASE = $(DESTDIR)/usr/lib/pop-shell/scripts 17 | endif 18 | INSTALLNAME = $(UUID) 19 | 20 | PROJECTS = color_dialog floating_exceptions 21 | 22 | $(info UUID is "$(UUID)") 23 | 24 | .PHONY: all clean install zip-file 25 | 26 | sources = src/*.ts *.css 27 | 28 | all: depcheck compile 29 | 30 | clean: 31 | rm -rf _build target 32 | 33 | # Configure local settings on system 34 | configure: 35 | sh scripts/configure.sh 36 | 37 | compile: $(sources) clean 38 | env PROJECTS="$(PROJECTS)" ./scripts/transpile.sh 39 | 40 | # Rebuild, install, reconfigure local settings, restart shell, and listen to journalctl logs 41 | debug: depcheck compile install configure enable restart-shell listen 42 | 43 | depcheck: 44 | @echo depcheck 45 | @if ! command -v tsc >/dev/null; then \ 46 | echo \ 47 | echo 'You must install TypeScript >= 3.8 to transpile: (node-typescript on Debian systems)'; \ 48 | exit 1; \ 49 | fi 50 | 51 | enable: 52 | gnome-extensions enable "pop-shell@system76.com" 53 | 54 | disable: 55 | gnome-extensions disable "pop-shell@system76.com" 56 | 57 | listen: 58 | journalctl -o cat -n 0 -f "$$(which gnome-shell)" | grep -v warning 59 | 60 | local-install: depcheck compile install configure restart-shell enable 61 | 62 | install: 63 | rm -rf $(INSTALLBASE)/$(INSTALLNAME) 64 | mkdir -p $(INSTALLBASE)/$(INSTALLNAME) $(PLUGIN_BASE) $(SCRIPTS_BASE) 65 | cp -r _build/* $(INSTALLBASE)/$(INSTALLNAME)/ 66 | 67 | uninstall: 68 | rm -rf $(INSTALLBASE)/$(INSTALLNAME) 69 | 70 | restart-shell: 71 | @echo "Restart shell!" 72 | ifneq ($(WAYLAND_DISPLAY),) # Don't restart if WAYLAND_DISPLAY is set 73 | @echo "WAYLAND_DISPLAY is set, not restarting shell"; 74 | else 75 | if bash -c 'xprop -root &> /dev/null'; then \ 76 | pkill -HUP gnome-shell; \ 77 | else \ 78 | gnome-session-quit --logout; \ 79 | fi 80 | sleep 3 81 | endif 82 | 83 | update-repository: 84 | git fetch origin 85 | git reset --hard origin/master 86 | git clean -fd 87 | 88 | zip-file: all 89 | cd _build && zip -qr "../$(UUID)_$(VERSION).zip" . 90 | 91 | .NOTPARALLEL: debug local-install 92 | -------------------------------------------------------------------------------- /TESTING.md: -------------------------------------------------------------------------------- 1 | # Testing 2 | 3 | This document provides a guideline for testing and verifying the expected behaviors of the project. When a patch is ready for testing, the checklists may be copied and marked as they are proven to be working. 4 | 5 | ## Logs 6 | 7 | To begin watching logs, open a terminal with the following command: 8 | 9 | ``` 10 | journalctl -o cat -n 0 -f "$(which gnome-shell)" | grep -v warning 11 | ``` 12 | 13 | Note that because the logs are from GNOME Shell, there will be messages from all installed extensions, and GNOME Shell itself. Pop Shell is fairly chatty though, so the majority of the logs should be from Pop Shell. Pop Shell logs are usually prepended with `pop-shell:`, but sometimes GNOME has internal errors and warnings surrounding those logs that could be useful for pointing to an issue that we can resolve in Pop Shell. 14 | 15 | ## Checklists 16 | 17 | Tasks for a tester to verify when approving a patch. Use complex window layouts and at least two displays. Turn on active hint during testing. 18 | 19 | ## With tiling enabled 20 | 21 | ### Tiling 22 | 23 | - [ ] Super direction keys changes focus in the correct direction 24 | - [ ] Windows moved with the keyboard tile into place 25 | - [ ] Windows moved with the mouse tile into place 26 | - [ ] Windows swap with the keyboard (test with different size windows) 27 | - [ ] Windows can be resized with the keyboard (Test resizing four windows above, below, right, and left to ensure shortcut consistency) 28 | - [ ] Windows can be resized with the mouse 29 | - [ ] Minimizing a window detaches it from the tree and re-tiles remaining windows 30 | - [ ] Unminimizing a window re-tiles the window 31 | - [ ] Maximizing with the keyboard (`Super` `M`) covers tiled windows 32 | - [ ] Unmaximizing with keyboard (`Super` `M`) re-tiles into place 33 | - [ ] Maximizing with the mouse covers tiled windows 34 | - [ ] Unmaximizing with mouse re-tiles into place 35 | - [ ] Full-screening removes the active hint and full-screens on one display 36 | - [ ] Unfull-screening adds the active hint and re-tiles into place 37 | - [ ] Maximizing a YouTube video fills the screen and unmaximizing retiles the browser in place 38 | - [ ] VIM shortcuts work as direction keys 39 | - [ ] `Super` `O` changes window orientation 40 | - [ ] `Super` `G` floats and then re-tiles a window 41 | - [ ] Float a window with `Super` `G`. It should be movable and resizeable in window management mode with keyboard keys 42 | - [ ] `Super` `Q` Closes a window 43 | - [ ] Turn off auto-tiling. New windows launch floating. 44 | - [ ] Turn on auto-tiling. Windows automatically tile. 45 | - [ ] Disabling and enabling auto-tiling correctly handles minimized, maximized, fullscreen, floating, and non-floating windows (This test needs a better definition, steps, or to be separated out.) 46 | 47 | ### Stacking 48 | 49 | - [ ] Windows can be moved into a stack. 50 | - [ ] Windows can be moved out of a stack. 51 | - [ ] Windows inside and outside of a stack can be swapped multiple times and in both directions. 52 | - [ ] Moving the last window out of a stack works as expected. 53 | - [ ] Stacks can be resized with the keyboard. 54 | - [ ] Stacks can be resized with the mouse. 55 | - [ ] Lock and unlock the screen-- stacks should still exist and windows should not have moved. 56 | - [ ] Full-screen an application within the stack, then quit the application-- the remaining tabs should be visible. 57 | 58 | ### Workspaces 59 | 60 | - [ ] Windows can be moved to another workspace with the keyboard 61 | - [ ] Windows can be moved to another workspace with the mouse 62 | - [ ] Windows can be moved to workspaces between existing workspaces 63 | - [ ] Moving windows to another workspace re-tiled the previous and new workspace 64 | - [ ] Active hint is present on the new workspace and once the window is returned to its previous workspace 65 | - [ ] Floating windows move across workspaces 66 | - [ ] Remove windows from the 2nd worspace in a 3 workspace setup. The 3rd workspace becomes the 2nd workspace, and tiling is unaffected by the move. 67 | 68 | ### Displays 69 | 70 | - [ ] Windows move across displays in adjustment mode with direction keys 71 | - [ ] Windows move across displays with the mouse 72 | - [ ] Changing the primary display moves the top bar. Window heights adjust on all monitors for the new position. 73 | - [ ] Unplug a display - windows from the display retile on a new workspace on the remaining display 74 | - [ ] Plug an additional display into a laptop - windows and workspaces don't changes 75 | - [ ] NOTE: Add vertical monitor layout test 76 | 77 | ### Launcher 78 | 79 | - [ ] All windows on all workspaces appear on launch 80 | - [ ] Choosing an app on another workspace moves workspaces and focus to that app 81 | - [ ] Launching an application works 82 | - [ ] Typing text and then removing it will re-show those windows 83 | - [ ] Search works for applications and windows 84 | - [ ] Search works for GNOME settings panels 85 | - [ ] Search for "Extensions". There should be only one entry. 86 | - [ ] The overlay hint correctly highlights the selected window 87 | - [ ] Open windows are sorted above applications (e.g. "web browser") 88 | - [ ] t: executes a command in a terminal 89 | - [ ] : executes a command in sh 90 | - [ ] = calculates an equation 91 | - [ ] Search results are as expected: 92 | - `cal` returns Calendar and Calculator before Color 93 | - `pops` returns Popsicle first 94 | - `shop` returns the Pop!_Shop first 95 | 96 | ### Window Titles 97 | 98 | - [ ] Disabling window titles using global (Pop Shell) option works for Shell Shortcuts, LibreOffice, etc. 99 | - [ ] Disabling window titles in Firefox works (Check debian and flatpak packages) 100 | 101 | ### Floating Exceptions 102 | 103 | - [ ] Add a window to floating exceptions-- it should float immediately. 104 | - [ ] Close and re-open the window-- it should float when opened. 105 | - [ ] Add an app to floating exceptions-- it should float immediately. 106 | - [ ] Close and re-open the app-- it should float when opened. 107 | 108 | ## With Tiling Disabled 109 | 110 | ### Tiling 111 | 112 | - [ ] Super direction keys changes focus in the correct direction 113 | - [ ] Windows can be moved with the keyboard 114 | - [ ] Windows can be moved with the mouse 115 | - [ ] Windows swap with the keyboard (test with different size windows) 116 | - [ ] Windows can be resized with the keyboard 117 | - [ ] Windows can be resized with the mouse 118 | - [ ] Windows can be half-tiled left and right with `Ctrl``Super``left`/`right` 119 | 120 | ### Displays 121 | 122 | - [ ] Windows move across displays in adjustment mode with directions keys 123 | - [ ] Windows move across displays with the mouse 124 | 125 | ### Miscellaneous 126 | 127 | - [ ] Close all windows-- no icons should be active in the GNOME launcher. 128 | - [ ] Open a window, enable tiling, stack the window, move to a different workspace, and disable tiling. The window should not become visible on the empty workspace. 129 | - [ ] With tiling still disabled, minimize the single window. The active hint should go away. 130 | - [ ] Maximize a window, then open another app with the Activities overview. The newly-opened app should be visible and focused. 131 | - [ ] Maximize a window, then open another app with the launcher. The newly-opened app should be visible and focused. 132 | -------------------------------------------------------------------------------- /dark.css: -------------------------------------------------------------------------------- 1 | .pop-shell-active-hint { 2 | border-style: solid; 3 | border-color: #FBB86C; 4 | border-radius: var(--active-hint-border-radius, 5px); 5 | box-shadow: inset 0 0 0 1px rgba(24, 23, 23, 0) 6 | } 7 | 8 | .pop-shell-overlay { 9 | background-color: rgba(53, 132, 228, 0.3); 10 | } 11 | 12 | .pop-shell-border-normal { 13 | border-width: 3px; 14 | } 15 | 16 | .pop-shell-border-maximize { 17 | border-width: 3px; 18 | } 19 | 20 | .pop-shell-search-element:select{ 21 | background: rgba(246, 246, 246, .2); 22 | border-radius: 5px; 23 | color: #EDEDED; 24 | } 25 | 26 | .pop-shell-search-icon { 27 | margin-right: 10px; 28 | } 29 | 30 | .pop-shell-search-cat { 31 | margin-right: 10px; 32 | } 33 | 34 | .pop-shell-search-element { 35 | padding-left: 10px; 36 | padding-right: 2px; 37 | padding-top: 6px; 38 | padding-bottom: 6px; 39 | } 40 | 41 | .pop-shell-tab { 42 | border: 1px solid #333; 43 | color: #000; 44 | padding: 0 1em; 45 | } 46 | 47 | .pop-shell-tab-active { 48 | background: #FBB86C; 49 | } 50 | 51 | .pop-shell-tab-inactive { 52 | background: #9B8E8A; 53 | } 54 | 55 | .pop-shell-tab-urgent { 56 | background: #D00; 57 | } 58 | -------------------------------------------------------------------------------- /debian/changelog: -------------------------------------------------------------------------------- 1 | pop-shell (1.1.0) focal; urgency=medium 2 | 3 | * Bug fixes & Launcher update w/ plugin support 4 | 5 | -- Michael Aaron Murphy Fri, 11 Dec 2020 13:27:27 -0700 6 | 7 | pop-shell (1.0.0) focal; urgency=medium 8 | 9 | * First stable release 10 | 11 | -- Michael Aaron Murphy Thu, 29 Oct 2020 11:57:36 -0600 12 | 13 | pop-shell (0.1.0) focal; urgency=medium 14 | 15 | * Initial release. 16 | 17 | -- Jeremy Soller Thu, 14 Nov 2019 21:35:19 -0700 18 | -------------------------------------------------------------------------------- /debian/control: -------------------------------------------------------------------------------- 1 | Source: pop-shell 2 | Section: gnome 3 | Priority: optional 4 | Maintainer: System76 5 | Build-Depends: debhelper-compat (=10), libglib2.0-bin, node-typescript 6 | Standards-Version: 3.9.8 7 | Homepage: https://github.com/pop-os/shell 8 | Vcs-Git: https://github.com/pop-os/shell 9 | 10 | Package: pop-shell 11 | Architecture: all 12 | Depends: 13 | ${misc:Depends}, 14 | pop-launcher, 15 | pop-shell-shortcuts, 16 | fd-find 17 | Recommends: pop-shell-plugin-system76-power, system76-scheduler 18 | Replaces: gnome-control-center-data (<< 1:3.38.1-2ubuntu1pop1~) 19 | Breaks: gnome-control-center-data (<< 1:3.38.1-2ubuntu1pop1~) 20 | Description: Pop!_OS Shell 21 | -------------------------------------------------------------------------------- /debian/copyright: -------------------------------------------------------------------------------- 1 | Format: http://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ 2 | Upstream-Name: pop-shell 3 | Source: https://github.com/pop-os/shell 4 | 5 | Files: * 6 | Copyright: 2020 System76 7 | License: GPL-3 8 | -------------------------------------------------------------------------------- /debian/install: -------------------------------------------------------------------------------- 1 | schemas/org.gnome.shell.extensions.pop-shell.gschema.xml usr/share/glib-2.0/schemas 2 | keybindings/*.xml usr/share/gnome-control-center/keybindings 3 | usr -------------------------------------------------------------------------------- /debian/rules: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | # -*- makefile -*- 3 | 4 | # Uncomment this to turn on verbose mode. 5 | #export DH_VERBOSE=1 6 | 7 | BASEDIR=debian/pop-shell/usr/share/gnome-shell/extensions/pop-shell@system76.com 8 | 9 | %: 10 | dh $@ 11 | 12 | override_dh_auto_install: 13 | dh_auto_install --destdir=debian/tmp -------------------------------------------------------------------------------- /debian/source/format: -------------------------------------------------------------------------------- 1 | 3.0 (native) 2 | -------------------------------------------------------------------------------- /highcontrast.css: -------------------------------------------------------------------------------- 1 | .pop-shell-active-hint { 2 | border-style: solid; 3 | border-color: #FBB86C; 4 | border-radius: var(--active-hint-border-radius, 5px); 5 | box-shadow: inset 0 0 0 1px rgba(24, 23, 23, 0) 6 | } 7 | 8 | .pop-shell-overlay { 9 | background-color: rgba(53, 132, 228, 0.3); 10 | } 11 | 12 | .pop-shell-border-normal { 13 | border-width: 3px; 14 | } 15 | 16 | .pop-shell-border-maximize { 17 | border-width: 3px; 18 | } 19 | 20 | .pop-shell-search-element:select{ 21 | background: #fff; 22 | border-radius: 5px; 23 | color: #000; 24 | } 25 | 26 | .pop-shell-search-icon { 27 | margin-right: 10px; 28 | } 29 | 30 | .pop-shell-search-cat { 31 | margin-right: 10px; 32 | } 33 | 34 | .pop-shell-search-element { 35 | padding-left: 10px; 36 | padding-right: 2px; 37 | padding-top: 6px; 38 | padding-bottom: 6px; 39 | } 40 | 41 | .pop-shell-tab { 42 | border: 1px solid #333; 43 | color: #000; 44 | padding: 0 1em; 45 | } 46 | 47 | .pop-shell-tab-active { 48 | background: #FBB86C; 49 | } 50 | 51 | .pop-shell-tab-inactive { 52 | background: #9B8E8A; 53 | } 54 | 55 | .pop-shell-tab-urgent { 56 | background: #D00; 57 | } 58 | 59 | .pop-shell-entry:indeterminate { 60 | font-style: italic 61 | } 62 | -------------------------------------------------------------------------------- /icons/pop-shell-auto-off-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 18 | 41 | 44 | 45 | 47 | 48 | 50 | image/svg+xml 51 | 53 | Pop Symbolic Icon Theme 54 | 56 | 57 | 59 | 61 | 63 | 65 | 67 | 69 | 71 | 72 | 73 | 74 | Pop Symbolic Icon Theme 76 | 78 | 81 | 85 | 86 | 89 | 93 | 94 | 97 | 101 | 102 | 105 | 109 | 110 | 111 | 115 | 119 | 123 | 127 | 131 | 135 | 139 | 143 | 149 | 155 | 163 | 164 | -------------------------------------------------------------------------------- /icons/pop-shell-auto-on-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 18 | 39 | 41 | 42 | 44 | image/svg+xml 45 | 47 | Pop Symbolic Icon Theme 48 | 50 | 51 | 53 | 55 | 57 | 59 | 61 | 63 | 65 | 66 | 67 | 68 | Pop Symbolic Icon Theme 70 | 72 | 75 | 79 | 80 | 83 | 87 | 88 | 91 | 95 | 96 | 99 | 103 | 104 | 105 | 109 | 113 | 117 | 121 | 125 | 129 | 133 | 141 | 149 | 157 | 161 | 162 | -------------------------------------------------------------------------------- /keybindings/10-pop-shell-move.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /keybindings/10-pop-shell-navigate.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /keybindings/10-pop-shell-tile.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /light.css: -------------------------------------------------------------------------------- 1 | .pop-shell-active-hint { 2 | border-style: solid; 3 | border-color: #FFAD00; 4 | border-radius: var(--active-hint-border-radius, 5px); 5 | box-shadow: inset 0 0 0 1px rgba(200, 200, 200, 0); 6 | } 7 | 8 | .pop-shell-overlay { 9 | background-color: rgba(53, 132, 228, 0.3); 10 | } 11 | 12 | .pop-shell-border-normal { 13 | border-width: 3px; 14 | } 15 | 16 | .pop-shell-border-maximize { 17 | border-width: 3px; 18 | } 19 | 20 | .pop-shell-search-element:select{ 21 | background: rgba(0, 0, 0, .1); 22 | border-radius: 5px; 23 | color: #393634; 24 | } 25 | 26 | .pop-shell-search-icon { 27 | margin-right: 10px; 28 | } 29 | 30 | .pop-shell-search-cat { 31 | margin-right: 10px; 32 | } 33 | 34 | .pop-shell-search-element { 35 | padding-left: 10px; 36 | padding-right: 2px; 37 | padding-top: 6px; 38 | padding-bottom: 6px; 39 | } 40 | 41 | .pop-shell-tab { 42 | border: 1px solid #333; 43 | color: #000; 44 | padding: 0 1em; 45 | } 46 | 47 | .pop-shell-tab-active { 48 | background: #FFAD00; 49 | } 50 | 51 | .pop-shell-tab-inactive { 52 | background: #9B8E8A; 53 | } 54 | 55 | .pop-shell-tab-urgent { 56 | background: #D00; 57 | } 58 | -------------------------------------------------------------------------------- /metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Pop Shell", 3 | "description": "Pop Shell", 4 | "version": 2, 5 | "uuid": "pop-shell@system76.com", 6 | "settings-schema": "org.gnome.shell.extensions.pop-shell", 7 | "shell-version": [ 8 | "45", 9 | "46", 10 | "47", 11 | "48" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module" 3 | } 4 | -------------------------------------------------------------------------------- /schemas/org.gnome.shell.extensions.pop-shell.gschema.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | false 7 | Show a hint around the active window 8 | 9 | 10 | 11 | 5 12 | 13 | Border radius for active window hint, in pixels 14 | 15 | 16 | 17 | false 18 | Allow showing launcher above fullscreen windows 19 | 20 | 21 | 22 | 2 23 | Gap between tiled windows, in pixels 24 | 25 | 26 | 27 | 2 28 | Gap surrounding tiled windows, in pixels 29 | 30 | 31 | 32 | true 33 | Show title bars on windows with server-side decorations 34 | 35 | 36 | 37 | true 38 | Handle minimized to tray windows 39 | 40 | 41 | 42 | true 43 | Move cursor to active window when navigating with keyboard shortcuts or touchpad gestures 44 | 45 | 46 | 47 | 0 48 | The location the mouse cursor focuses when selecting a window 49 | 50 | 51 | 52 | 53 | 64 54 | Size of a column in the display grid 55 | 56 | 57 | 58 | 64 59 | Size of a row in the display grid 60 | 61 | 62 | 63 | false 64 | Hide the outer gap when a tree contains only one window 65 | 66 | 67 | 68 | false 69 | Snaps windows to the tiling grid on drop 70 | 71 | 72 | 73 | false 74 | Tile launched windows by default 75 | 76 | 77 | 78 | true 79 | Allow for stacking windows as a result of dragging a window with mouse 80 | 81 | 82 | 83 | 0 84 | Maximum width of tiled windows, in pixels (0 to disable) 85 | 86 | 87 | 88 | 89 | Left','KP_Left','h']]]> 90 | Focus left window 91 | 92 | 93 | 94 | Down','KP_Down','j']]]> 95 | Focus down window 96 | 97 | 98 | 99 | Up','KP_Up','k']]]> 100 | Focus up window 101 | 102 | 103 | 104 | Right','KP_Right','l']]]> 105 | Focus right window 106 | 107 | 108 | 109 | 110 | slash']]]> 111 | Search key combo 112 | 113 | 114 | 115 | 116 | 117 | Toggle stacking mode inside management mode 118 | 119 | 120 | 121 | s']]]> 122 | Toggle stacking mode outside management mode 123 | 124 | 125 | 126 | 127 | Toggle tiling orientation 128 | 129 | 130 | 131 | Return','KP_Enter']]]> 132 | Enter tiling mode 133 | 134 | 135 | 136 | 137 | Accept tiling changes 138 | 139 | 140 | 141 | 142 | Reject tiling changes 143 | 144 | 145 | 146 | g']]]> 147 | Toggles a window between floating and tiling 148 | 149 | 150 | 151 | 152 | y']]]> 153 | Toggles auto-tiling on and off 154 | 155 | 156 | 157 | 158 | 159 | Move window left 160 | 161 | 162 | 163 | 164 | Move window down 165 | 166 | 167 | 168 | 169 | Move window up 170 | 171 | 172 | 173 | 174 | Move window right 175 | 176 | 177 | 178 | 179 | Move window left 180 | 181 | 182 | 183 | 184 | Move window down 185 | 186 | 187 | 188 | 189 | Move window up 190 | 191 | 192 | 193 | 194 | Move window right 195 | 196 | 197 | 198 | o']]]> 199 | Toggle tiling orientation 200 | 201 | 202 | 203 | 204 | Left','KP_Left','h']]]> 205 | Resize window left 206 | 207 | 208 | 209 | Down','KP_Down','j']]]> 210 | Resize window down 211 | 212 | 213 | 214 | Up','KP_Up','k']]]> 215 | Resize window up 216 | 217 | 218 | 219 | Right','KP_Right','l']]]> 220 | Resize window right 221 | 222 | 223 | 224 | 225 | Left','KP_Left','h']]]> 226 | Swap window left 227 | 228 | 229 | 230 | Down','KP_Down','j']]]> 231 | Swap window down 232 | 233 | 234 | 235 | Up','KP_Up','k']]]> 236 | Swap window up 237 | 238 | 239 | 240 | Right','KP_Right','l']]]> 241 | Swap window right 242 | 243 | 244 | 245 | 246 | 247 | Down','KP_Down','j']]]> 248 | Move window to the lower workspace 249 | 250 | 251 | 252 | Up','KP_Up','k']]]> 253 | Move window to the upper workspace 254 | 255 | 256 | 257 | Down','KP_Down','j']]]> 258 | Move window to the lower monitor 259 | 260 | 261 | 262 | Up','KP_Up','k']]]> 263 | Move window to the upper monitor 264 | 265 | 266 | 267 | Left','KP_Left','h']]]> 268 | Move window to the leftward monitor 269 | 270 | 271 | 272 | Right','KP_Right','l']]]> 273 | Move window to the rightward monitor 274 | 275 | 276 | 277 | 'rgba(251, 184, 108, 1)' 278 | The current active-hint-color in RGBA 279 | 280 | 281 | 0 282 | 283 | Derive some log4j level/order 284 | 0 - OFF 285 | 1 - ERROR 286 | 2 - WARN 287 | 3 - INFO 288 | 4 - DEBUG 289 | 290 | 291 | 292 | 293 | 294 | -------------------------------------------------------------------------------- /screenshot.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pop-os/shell/b3fc4253dc29b30fb52ac5eef5d3af643a46d18c/screenshot.webp -------------------------------------------------------------------------------- /scripts/configure.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -ex 4 | 5 | shortcut_applied() { 6 | # Check if user confirmed overriding shortcuts 7 | if test -f "./.confirm_shortcut_change"; then 8 | echo "Shortcut change already confirmed" 9 | return 0 10 | fi 11 | 12 | read -p "Pop shell will override your default shortcuts. Are you sure? (y/n) " CONT 13 | if test "$CONT" = "y"; then 14 | touch "./.confirm_shortcut_change" 15 | return 1 16 | else 17 | echo "Cancelled" 18 | return 0 19 | fi 20 | } 21 | 22 | set_keybindings() { 23 | if shortcut_applied; then 24 | return 0 25 | fi 26 | 27 | left="h" 28 | down="j" 29 | up="k" 30 | right="l" 31 | 32 | KEYS_GNOME_WM=/org/gnome/desktop/wm/keybindings 33 | KEYS_GNOME_SHELL=/org/gnome/shell/keybindings 34 | KEYS_MUTTER=/org/gnome/mutter/keybindings 35 | KEYS_MEDIA=/org/gnome/settings-daemon/plugins/media-keys 36 | KEYS_MUTTER_WAYLAND_RESTORE=/org/gnome/mutter/wayland/keybindings/restore-shortcuts 37 | 38 | # Disable incompatible shortcuts 39 | # Restore the keyboard shortcuts: disable Escape 40 | dconf write ${KEYS_MUTTER_WAYLAND_RESTORE} "@as []" 41 | # Hide window: disable h 42 | dconf write ${KEYS_GNOME_WM}/minimize "@as ['comma']" 43 | # Open the application menu: disable m 44 | dconf write ${KEYS_GNOME_SHELL}/open-application-menu "@as []" 45 | # Toggle message tray: disable m 46 | dconf write ${KEYS_GNOME_SHELL}/toggle-message-tray "@as ['v']" 47 | # Show the activities overview: disable s 48 | dconf write ${KEYS_GNOME_SHELL}/toggle-overview "@as []" 49 | # Switch to workspace left: disable Left 50 | dconf write ${KEYS_GNOME_WM}/switch-to-workspace-left "@as []" 51 | # Switch to workspace right: disable Right 52 | dconf write ${KEYS_GNOME_WM}/switch-to-workspace-right "@as []" 53 | # Maximize window: disable Up 54 | dconf write ${KEYS_GNOME_WM}/maximize "@as []" 55 | # Restore window: disable Down 56 | dconf write ${KEYS_GNOME_WM}/unmaximize "@as []" 57 | # Move to monitor up: disable Up 58 | dconf write ${KEYS_GNOME_WM}/move-to-monitor-up "@as []" 59 | # Move to monitor down: disable Down 60 | dconf write ${KEYS_GNOME_WM}/move-to-monitor-down "@as []" 61 | 62 | # Super + direction keys, move window left and right monitors, or up and down workspaces 63 | # Move window one monitor to the left 64 | dconf write ${KEYS_GNOME_WM}/move-to-monitor-left "@as []" 65 | # Move window one workspace down 66 | dconf write ${KEYS_GNOME_WM}/move-to-workspace-down "@as []" 67 | # Move window one workspace up 68 | dconf write ${KEYS_GNOME_WM}/move-to-workspace-up "@as []" 69 | # Move window one monitor to the right 70 | dconf write ${KEYS_GNOME_WM}/move-to-monitor-right "@as []" 71 | 72 | # Super + Ctrl + direction keys, change workspaces, move focus between monitors 73 | # Move to workspace below 74 | dconf write ${KEYS_GNOME_WM}/switch-to-workspace-down "['Down','${down}']" 75 | # Move to workspace above 76 | dconf write ${KEYS_GNOME_WM}/switch-to-workspace-up "['Up','${up}']" 77 | 78 | # Disable tiling to left / right of screen 79 | dconf write ${KEYS_MUTTER}/toggle-tiled-left "@as []" 80 | dconf write ${KEYS_MUTTER}/toggle-tiled-right "@as []" 81 | 82 | # Toggle maximization state 83 | dconf write ${KEYS_GNOME_WM}/toggle-maximized "['m']" 84 | # Lock screen 85 | dconf write ${KEYS_MEDIA}/screensaver "['Escape']" 86 | # Home folder 87 | dconf write ${KEYS_MEDIA}/home "['f']" 88 | # Launch email client 89 | dconf write ${KEYS_MEDIA}/email "['e']" 90 | # Launch web browser 91 | dconf write ${KEYS_MEDIA}/www "['b']" 92 | # Launch terminal 93 | dconf write ${KEYS_MEDIA}/terminal "['t']" 94 | # Rotate Video Lock 95 | dconf write ${KEYS_MEDIA}/rotate-video-lock-static "@as []" 96 | 97 | # Close Window 98 | dconf write ${KEYS_GNOME_WM}/close "['q', 'F4']" 99 | } 100 | 101 | if ! command -v gnome-extensions >/dev/null; then 102 | echo 'You must install gnome-extensions to configure or enable via this script' 103 | '(`gnome-shell` on Debian systems, `gnome-extensions` on openSUSE systems.)' 104 | exit 1 105 | fi 106 | 107 | set_keybindings 108 | 109 | # Make sure user extensions are enabled 110 | dconf write /org/gnome/shell/disable-user-extensions false 111 | 112 | # Use a window placement behavior which works better for tiling 113 | 114 | if gnome-extensions list | grep native-window; then 115 | gnome-extensions enable $(gnome-extensions list | grep native-window) 116 | fi 117 | 118 | # Workspaces spanning displays works better with Pop Shell 119 | dconf write /org/gnome/mutter/workspaces-only-on-primary false 120 | -------------------------------------------------------------------------------- /scripts/transpile.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -ex 3 | 4 | pwd=$(pwd) 5 | 6 | # In goes standard JS. Out comes GJS-compatible JS 7 | transpile() { 8 | cp "${src}" "${dest}" 9 | # cat "${src}" | sed -e 's#export function#function#g' \ 10 | # -e 's#export var#var#g' \ 11 | # -e 's#export const#var#g' \ 12 | # -e 's#Object.defineProperty(exports, "__esModule", { value: true });#var exports = {};#g' \ 13 | # | sed -E 's/export class (\w+)/var \1 = class \1/g' \ 14 | # | sed -E "s/import \* as (\w+) from '(\w+)'/const \1 = Me.imports.\2/g" > "${dest}" 15 | } 16 | 17 | rm -rf _build 18 | 19 | glib-compile-schemas schemas & 20 | 21 | # Transpile to JavaScript 22 | 23 | for proj in ${PROJECTS}; do 24 | mkdir -p _build/"${proj}" 25 | tsc --p src/"${proj}" 26 | done 27 | 28 | tsc 29 | 30 | wait 31 | 32 | # Convert JS to GJS-compatible scripts 33 | 34 | cp -r metadata.json icons schemas *.css _build & 35 | 36 | for src in $(find target -name '*.js'); do 37 | dest=$(echo "$src" | sed s#target#_build#g) 38 | transpile 39 | done 40 | 41 | wait 42 | -------------------------------------------------------------------------------- /src/arena.ts: -------------------------------------------------------------------------------- 1 | /** Hop slot arena allocator */ 2 | export class Arena { 3 | private slots: Array = new Array(); 4 | 5 | private unused: Array = new Array(); 6 | 7 | truncate(n: number) { 8 | this.slots.splice(n); 9 | this.unused.splice(n); 10 | } 11 | 12 | get(n: number): null | T { 13 | return this.slots[n]; 14 | } 15 | 16 | insert(v: T): number { 17 | let n; 18 | const slot = this.unused.pop(); 19 | if (slot !== undefined) { 20 | n = slot; 21 | this.slots[n] = v; 22 | } else { 23 | n = this.slots.length; 24 | this.slots.push(v); 25 | } 26 | 27 | return n; 28 | } 29 | 30 | remove(n: number): null | T { 31 | if (this.slots[n] === null) return null; 32 | const v = this.slots[n]; 33 | this.slots[n] = null; 34 | this.unused.push(n); 35 | return v; 36 | } 37 | 38 | *values(): IterableIterator { 39 | for (const v of this.slots) { 40 | if (v !== null) yield v; 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/color_dialog/src/main.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/gjs --module 2 | 3 | import Gio from 'gi://Gio'; 4 | import GLib from 'gi://GLib'; 5 | import Gtk from 'gi://Gtk?version=3.0'; 6 | import Gdk from 'gi://Gdk'; 7 | 8 | const EXT_PATH_DEFAULTS = [ 9 | GLib.get_home_dir() + '/.local/share/gnome-shell/extensions/', 10 | '/usr/share/gnome-shell/extensions/', 11 | ]; 12 | const DEFAULT_HINT_COLOR = 'rgba(251, 184, 108, 1)'; //pop-orange 13 | 14 | /** Look for the extension in path */ 15 | function getExtensionPath(uuid: string) { 16 | let ext_path = null; 17 | 18 | for (let i = 0; i < EXT_PATH_DEFAULTS.length; i++) { 19 | let path = EXT_PATH_DEFAULTS[i]; 20 | let file = Gio.File.new_for_path(path + uuid); 21 | log(file.get_path()); 22 | if (file.query_exists(null)) { 23 | ext_path = file; 24 | break; 25 | } 26 | } 27 | 28 | return ext_path; 29 | } 30 | 31 | function getSettings(schema: string) { 32 | let extensionPath = getExtensionPath('pop-shell@system76.com'); 33 | if (!extensionPath) throw new Error('getSettings() can only be called when extension is available'); 34 | 35 | // The following will load a custom path for a user defined gsettings/schemas folder 36 | const GioSSS = Gio.SettingsSchemaSource; 37 | const schemaDir = extensionPath.get_child('schemas'); 38 | 39 | let schemaSource = schemaDir.query_exists(null) 40 | ? GioSSS.new_from_directory(schemaDir.get_path(), GioSSS.get_default(), false) 41 | : GioSSS.get_default(); 42 | 43 | const schemaObj = schemaSource.lookup(schema, true); 44 | 45 | if (!schemaObj) { 46 | throw new Error('Schema ' + schema + ' could not be found for extension '); 47 | } 48 | return new Gio.Settings({ settings_schema: schemaObj }); 49 | } 50 | /** 51 | * Launch a Gtk.ColorChooserDialog. And then save the color RGBA/alpha values in GSettings of Pop-Shell. 52 | * Using the settings.connect('changed') mechanism, the extension is able to listen to when the color changes in realtime. 53 | */ 54 | function launch_color_dialog() { 55 | let popshell_settings = getSettings('org.gnome.shell.extensions.pop-shell'); 56 | 57 | let color_dialog = new Gtk.ColorChooserDialog({ 58 | title: 'Choose Color', 59 | }); 60 | color_dialog.show_editor = true; 61 | color_dialog.show_all(); 62 | 63 | // Use the new spec format for Gtk.Color thru Gdk.RGBA 64 | let rgba = new Gdk.RGBA(); 65 | if (rgba.parse(popshell_settings.get_string('hint-color-rgba'))) { 66 | color_dialog.set_rgba(rgba); 67 | } else { 68 | rgba.parse(DEFAULT_HINT_COLOR); 69 | color_dialog.set_rgba(rgba); 70 | } 71 | 72 | let response = color_dialog.run(); 73 | 74 | if (response === Gtk.ResponseType.CANCEL) { 75 | color_dialog.destroy(); 76 | } else if (response === Gtk.ResponseType.OK) { 77 | // save the selected RGBA to GSettings 78 | // TODO, save alpha instead of always 1.0 79 | popshell_settings.set_string('hint-color-rgba', color_dialog.get_rgba().to_string()); 80 | Gio.Settings.sync(); 81 | color_dialog.destroy(); 82 | } 83 | } 84 | 85 | Gtk.init(null); 86 | 87 | launch_color_dialog(); 88 | -------------------------------------------------------------------------------- /src/color_dialog/src/mod.d.ts: -------------------------------------------------------------------------------- 1 | declare const log: (arg: string) => void, imports: any, _: (arg: string) => string; 2 | 3 | declare module 'gi://*' { 4 | let data: any; 5 | export default data; 6 | } 7 | -------------------------------------------------------------------------------- /src/color_dialog/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": true, 3 | "compilerOptions": { 4 | "target": "es2015", 5 | "strict": true, 6 | "outDir": "../../target/color_dialog", 7 | "forceConsistentCasingInFileNames": true, 8 | "downlevelIteration": true, 9 | "lib": ["es2015"], 10 | "pretty": true, 11 | "removeComments": true, 12 | "incremental": true, 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true 15 | }, 16 | "include": ["src/*.ts"] 17 | } 18 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import GLib from 'gi://GLib'; 2 | import Gio from 'gi://Gio'; 3 | 4 | const CONF_DIR: string = GLib.get_user_config_dir() + '/pop-shell'; 5 | export var CONF_FILE: string = CONF_DIR + '/config.json'; 6 | 7 | export interface FloatRule { 8 | class?: string; 9 | title?: string; 10 | disabled?: boolean; 11 | } 12 | 13 | interface Ok { 14 | tag: 0; 15 | value: T; 16 | } 17 | 18 | interface Error { 19 | tag: 1; 20 | why: string; 21 | } 22 | 23 | type Result = Ok | Error; 24 | 25 | export const DEFAULT_FLOAT_RULES: Array = [ 26 | { class: 'Authy Desktop' }, 27 | { class: 'Com.github.amezin.ddterm' }, 28 | { class: 'Com.github.donadigo.eddy' }, 29 | { class: 'Conky' }, 30 | { title: 'Discord Updater' }, 31 | { class: 'Enpass', title: 'Enpass Assistant' }, 32 | { class: 'Floating Window Exceptions' }, 33 | { class: 'Gjs', title: 'Settings' }, 34 | { class: 'Gnome-initial-setup' }, 35 | { class: 'Gnome-terminal', title: 'Preferences – General' }, 36 | { class: 'Guake' }, 37 | { class: 'Io.elementary.sideload' }, 38 | { title: 'JavaEmbeddedFrame' }, 39 | { class: 'KotatogramDesktop', title: 'Media viewer' }, 40 | { class: 'Mozilla VPN' }, 41 | { class: 'update-manager', title: 'Software Updater' }, 42 | { class: 'Solaar' }, 43 | { class: 'Steam', title: '^((?!Steam).)*$' }, 44 | { class: 'Steam', title: '^.*(Guard|Login).*' }, 45 | { class: 'TelegramDesktop', title: 'Media viewer' }, 46 | { class: 'Zotero', title: 'Quick Format Citation' }, 47 | { class: 'firefox', title: '^(?!.*Mozilla Firefox).*$' }, 48 | { class: 'gnome-screenshot' }, 49 | { class: 'ibus-.*' }, 50 | { class: 'jetbrains-toolbox' }, 51 | { class: 'jetbrains-webstorm', title: 'Customize WebStorm' }, 52 | { class: 'jetbrains-webstorm', title: 'License Activation' }, 53 | { class: 'jetbrains-webstorm', title: 'Welcome to WebStorm' }, 54 | { class: 'krunner' }, 55 | { class: 'pritunl' }, 56 | { class: 're.sonny.Junction' }, 57 | { class: 'system76-driver' }, 58 | { class: 'tilda' }, 59 | { class: 'zoom' }, 60 | { class: '^.*action=join.*$' }, 61 | { class: 'gjs' }, 62 | ]; 63 | 64 | export interface WindowRule { 65 | class?: string; 66 | title?: string; 67 | disabled?: boolean; 68 | } 69 | 70 | /** 71 | * These windows will skip showing in Overview, Thumbnails or SwitcherList 72 | * And any rule here should be added on the DEFAULT_RULES above 73 | */ 74 | export const SKIPTASKBAR_EXCEPTIONS: Array = [ 75 | { class: 'Conky' }, 76 | { class: 'gjs' }, 77 | { class: 'Guake' }, 78 | { class: 'Com.github.amezin.ddterm' }, 79 | { class: 'plank' }, 80 | ]; 81 | 82 | export interface FloatRule { 83 | class?: string; 84 | title?: string; 85 | } 86 | 87 | export class Config { 88 | /** List of windows that should float, regardless of their WM hints */ 89 | float: Array = []; 90 | 91 | /** 92 | * List of Windows with skip taskbar true but still hidden in Overview, 93 | * Switchers, Workspace Thumbnails 94 | */ 95 | skiptaskbarhidden: Array = []; 96 | 97 | /** Logs window details on focus of window */ 98 | log_on_focus: boolean = false; 99 | 100 | /** Add a floating exception which matches by wm_class */ 101 | add_app_exception(wmclass: string) { 102 | for (const r of this.float) { 103 | if (r.class === wmclass && r.title === undefined) return; 104 | } 105 | 106 | this.float.push({ class: wmclass }); 107 | this.sync_to_disk(); 108 | } 109 | 110 | /** Add a floating exception which matches by wm_title */ 111 | add_window_exception(wmclass: string, title: string) { 112 | for (const r of this.float) { 113 | if (r.class === wmclass && r.title === title) return; 114 | } 115 | 116 | this.float.push({ class: wmclass, title }); 117 | this.sync_to_disk(); 118 | } 119 | 120 | window_shall_float(wclass: string, title: string): boolean { 121 | for (const rule of this.float.concat(DEFAULT_FLOAT_RULES)) { 122 | if (rule.class) { 123 | if (!new RegExp(rule.class, 'i').test(wclass)) { 124 | continue; 125 | } 126 | } 127 | 128 | if (rule.title) { 129 | if (!new RegExp(rule.title, 'i').test(title)) { 130 | continue; 131 | } 132 | } 133 | 134 | return rule.disabled ? false : true; 135 | } 136 | 137 | return false; 138 | } 139 | 140 | skiptaskbar_shall_hide(meta_window: any) { 141 | let wmclass = meta_window.get_wm_class(); 142 | let wmtitle = meta_window.get_title(); 143 | 144 | if (!meta_window.is_skip_taskbar()) return false; 145 | 146 | for (const rule of this.skiptaskbarhidden.concat(SKIPTASKBAR_EXCEPTIONS)) { 147 | if (rule.class) { 148 | if (!new RegExp(rule.class, 'i').test(wmclass)) { 149 | continue; 150 | } 151 | } 152 | 153 | if (rule.title) { 154 | if (!new RegExp(rule.title, 'i').test(wmtitle)) { 155 | continue; 156 | } 157 | } 158 | 159 | return rule.disabled ? false : true; 160 | } 161 | 162 | return false; 163 | } 164 | 165 | reload() { 166 | const conf = Config.from_config(); 167 | 168 | if (conf.tag === 0) { 169 | let c = conf.value; 170 | this.float = c.float; 171 | this.log_on_focus = c.log_on_focus; 172 | } else { 173 | log(`error loading conf: ${conf.why}`); 174 | } 175 | } 176 | 177 | rule_disabled(rule: FloatRule): boolean { 178 | for (const value of this.float.values()) { 179 | if (value.disabled && rule.class === value.class && value.title === rule.title) { 180 | return true; 181 | } 182 | } 183 | 184 | return false; 185 | } 186 | 187 | to_json(): string { 188 | return JSON.stringify(this, set_to_json, 2); 189 | } 190 | 191 | toggle_system_exception(wmclass: string | undefined, wmtitle: string | undefined, disabled: boolean) { 192 | if (disabled) { 193 | for (const value of DEFAULT_FLOAT_RULES) { 194 | if (value.class === wmclass && value.title === wmtitle) { 195 | value.disabled = disabled; 196 | this.float.push(value); 197 | this.sync_to_disk(); 198 | return; 199 | } 200 | } 201 | } 202 | 203 | let index = 0; 204 | let found = false; 205 | for (const value of this.float) { 206 | if (value.class === wmclass && value.title === wmtitle) { 207 | found = true; 208 | break; 209 | } 210 | index += 1; 211 | } 212 | 213 | if (found) swap_remove(this.float, index); 214 | 215 | this.sync_to_disk(); 216 | } 217 | 218 | remove_user_exception(wmclass: string | undefined, wmtitle: string | undefined) { 219 | let index = 0; 220 | let found = new Array(); 221 | for (const value of this.float.values()) { 222 | if (value.class === wmclass && value.title === wmtitle) { 223 | found.push(index); 224 | } 225 | 226 | index += 1; 227 | } 228 | 229 | if (found.length !== 0) { 230 | for (const idx of found) swap_remove(this.float, idx); 231 | 232 | this.sync_to_disk(); 233 | } 234 | } 235 | 236 | static from_json(json: string): Config { 237 | try { 238 | return JSON.parse(json); 239 | } catch (error) { 240 | return new Config(); 241 | } 242 | } 243 | 244 | private static from_config(): Result { 245 | const stream = Config.read(); 246 | if (stream.tag === 1) return stream; 247 | let value = Config.from_json(stream.value); 248 | return { tag: 0, value }; 249 | } 250 | 251 | private static gio_file(): Result { 252 | try { 253 | const conf = Gio.File.new_for_path(CONF_FILE); 254 | 255 | if (!conf.query_exists(null)) { 256 | const dir = Gio.File.new_for_path(CONF_DIR); 257 | if (!dir.query_exists(null) && !dir.make_directory(null)) { 258 | return { tag: 1, why: 'failed to create pop-shell config directory' }; 259 | } 260 | 261 | const example = new Config(); 262 | example.float.push({ class: 'pop-shell-example', title: 'pop-shell-example' }); 263 | 264 | conf.create(Gio.FileCreateFlags.NONE, null).write_all(JSON.stringify(example, undefined, 2), null); 265 | } 266 | 267 | return { tag: 0, value: conf }; 268 | } catch (why) { 269 | return { tag: 1, why: `Gio.File I/O error: ${why}` }; 270 | } 271 | } 272 | 273 | private static read(): Result { 274 | try { 275 | const file = Config.gio_file(); 276 | if (file.tag === 1) return file; 277 | 278 | const [, buffer] = file.value.load_contents(null); 279 | 280 | return { tag: 0, value: imports.byteArray.toString(buffer) }; 281 | } catch (why) { 282 | return { tag: 1, why: `failed to read pop-shell config: ${why}` }; 283 | } 284 | } 285 | 286 | private static write(data: string): Result { 287 | try { 288 | const file = Config.gio_file(); 289 | if (file.tag === 1) return file; 290 | 291 | file.value.replace_contents(data, null, false, Gio.FileCreateFlags.NONE, null); 292 | 293 | return { tag: 0, value: file.value }; 294 | } catch (why) { 295 | return { tag: 1, why: `failed to write to config: ${why}` }; 296 | } 297 | } 298 | 299 | sync_to_disk() { 300 | Config.write(this.to_json()); 301 | } 302 | } 303 | 304 | function set_to_json(_key: string, value: any) { 305 | if (typeof value === 'object' && value instanceof Set) { 306 | return [...value]; 307 | } 308 | return value; 309 | } 310 | 311 | function swap_remove(array: Array, index: number): T | undefined { 312 | array[index] = array[array.length - 1]; 313 | return array.pop(); 314 | } 315 | -------------------------------------------------------------------------------- /src/context.ts: -------------------------------------------------------------------------------- 1 | import St from 'gi://St'; 2 | 3 | import * as Main from 'resource:///org/gnome/shell/ui/main.js'; 4 | import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; 5 | 6 | export function addMenu(widget: any, request: (menu: St.Widget) => void): St.Widget { 7 | const menu = new PopupMenu.PopupMenu(widget, 0.0, St.Side.TOP, 0); 8 | Main.uiGroup.add_child(menu.actor); 9 | menu.actor.hide(); 10 | menu.actor.add_style_class_name('panel-menu'); 11 | 12 | // Intercept right click events on the launcher app's button 13 | widget.connect('button-press-event', (_: any, event: any) => { 14 | if (event.get_button() === 3) { 15 | request(menu); 16 | } 17 | }); 18 | 19 | return menu; 20 | } 21 | 22 | export function addContext(menu: St.Widget, name: string, activate: () => void) { 23 | const menu_item = appendMenuItem(menu, name); 24 | 25 | menu_item.connect('activate', () => activate()); 26 | } 27 | 28 | function appendMenuItem(menu: any, label: string) { 29 | let item = new PopupMenu.PopupMenuItem(label); 30 | menu.addMenuItem(item); 31 | return item; 32 | } 33 | -------------------------------------------------------------------------------- /src/dbus_service.ts: -------------------------------------------------------------------------------- 1 | import Gio from 'gi://Gio'; 2 | 3 | const IFACE: string = ` 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | `; 24 | 25 | export class Service { 26 | dbus: any; 27 | id: any; 28 | 29 | FocusLeft: () => void = () => {}; 30 | FocusRight: () => void = () => {}; 31 | FocusUp: () => void = () => {}; 32 | FocusDown: () => void = () => {}; 33 | Launcher: () => void = () => {}; 34 | WindowFocus: (window: [number, number]) => void = () => {}; 35 | WindowList: () => Array<[[number, number], string, string, string]> = () => []; 36 | WindowQuit: (window: [number, number]) => void = () => {}; 37 | 38 | constructor() { 39 | this.dbus = Gio.DBusExportedObject.wrapJSObject(IFACE, this); 40 | 41 | const onBusAcquired = (conn: any) => { 42 | this.dbus.export(conn, '/com/System76/PopShell'); 43 | }; 44 | 45 | function onNameAcquired() {} 46 | 47 | function onNameLost() {} 48 | 49 | this.id = Gio.bus_own_name( 50 | Gio.BusType.SESSION, 51 | 'com.System76.PopShell', 52 | Gio.BusNameOwnerFlags.NONE, 53 | onBusAcquired, 54 | onNameAcquired, 55 | onNameLost, 56 | ); 57 | } 58 | 59 | destroy() { 60 | Gio.bus_unown_name(this.id); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/dialog_add_exception.ts: -------------------------------------------------------------------------------- 1 | import * as Lib from './lib.js'; 2 | import St from 'gi://St'; 3 | import Clutter from 'gi://Clutter'; 4 | 5 | import * as ModalDialog from 'resource:///org/gnome/shell/ui/modalDialog.js'; 6 | 7 | export class AddExceptionDialog { 8 | dialog: Shell.ModalDialog = new ModalDialog.ModalDialog({ 9 | styleClass: 'pop-shell-search modal-dialog', 10 | destroyOnClose: false, 11 | shellReactive: true, 12 | shouldFadeIn: false, 13 | shouldFadeOut: false, 14 | }); 15 | 16 | constructor(cancel: () => void, this_app: () => void, current_window: () => void, on_close: () => void) { 17 | let title = St.Label.new('Add Floating Window Exception'); 18 | title.set_x_align(Clutter.ActorAlign.CENTER); 19 | title.set_style('font-weight: bold'); 20 | 21 | let desc = St.Label.new('Float the selected window or all windows from the application.'); 22 | desc.set_x_align(Clutter.ActorAlign.CENTER); 23 | 24 | let l = this.dialog.contentLayout; 25 | 26 | l.add_child(title); 27 | l.add_child(desc); 28 | 29 | this.dialog.contentLayout.width = Math.max(Lib.current_monitor().width / 4, 640); 30 | 31 | this.dialog.addButton({ 32 | label: 'Cancel', 33 | action: () => { 34 | cancel(); 35 | on_close(); 36 | this.close(); 37 | }, 38 | key: Clutter.KEY_Escape, 39 | }); 40 | 41 | this.dialog.addButton({ 42 | label: "This App's Windows", 43 | action: () => { 44 | this_app(); 45 | on_close(); 46 | this.close(); 47 | }, 48 | }); 49 | 50 | this.dialog.addButton({ 51 | label: 'Current Window Only', 52 | action: () => { 53 | current_window(); 54 | on_close(); 55 | this.close(); 56 | }, 57 | }); 58 | } 59 | 60 | close() { 61 | this.dialog.close(global.get_current_time()); 62 | } 63 | 64 | show() { 65 | this.dialog.show(); 66 | } 67 | 68 | open() { 69 | this.dialog.open(global.get_current_time(), false); 70 | this.show(); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/ecs.ts: -------------------------------------------------------------------------------- 1 | /// A generational entity ID 2 | /// 3 | /// Solves the ABA problem by tagging indexes with generations. The generation is used to 4 | /// determine if an entity is the same entity as the one which previously owned an 5 | /// assigned component at a given index. 6 | /// 7 | /// # Implementation Notes 8 | /// 9 | /// - The first 32-bit integer is the index. 10 | /// - The second 32-bit integer is the generation. 11 | 12 | import { Executor } from './executor.js'; 13 | 14 | export type Entity = [number, number]; 15 | 16 | export function entity_eq(a: Entity, b: Entity): boolean { 17 | return a[0] == b[0] && b[1] == b[1]; 18 | } 19 | 20 | export function entity_new(pos: number, gen: number): Entity { 21 | return [pos, gen]; 22 | } 23 | 24 | /// Storages hold components of a specific type, and define these associations on entities 25 | /// 26 | /// # Implementation Notes 27 | /// 28 | /// Consists of an `Array` which uses the entity ID as the index into that array. Each 29 | /// value in the array is an array which contains the entity's generation, and the 30 | /// component which was assigned to it. The generation is used to determine if an 31 | /// assigned component is stale on component lookup. 32 | export class Storage { 33 | private store: Array<[number, T] | null>; 34 | 35 | constructor() { 36 | this.store = new Array(); 37 | } 38 | 39 | /// Private method for iterating across allocated slots 40 | *_iter(): IterableIterator<[number, [number, T]]> { 41 | let idx = 0; 42 | for (const slot of this.store) { 43 | if (slot) yield [idx, slot]; 44 | idx += 1; 45 | } 46 | } 47 | 48 | /// Iterates across each stored component, and their entities 49 | *iter(): IterableIterator<[Entity, T]> { 50 | for (const [idx, [gen, value]] of this._iter()) { 51 | yield [entity_new(idx, gen), value]; 52 | } 53 | } 54 | 55 | /// Finds values with the matching component 56 | *find(func: (value: T) => boolean): IterableIterator { 57 | for (const [idx, [gen, value]] of this._iter()) { 58 | if (func(value)) yield entity_new(idx, gen); 59 | } 60 | } 61 | 62 | /// Iterates across each stored component 63 | *values(): IterableIterator { 64 | for (const [, [, value]] of this._iter()) { 65 | yield value; 66 | } 67 | } 68 | 69 | /** 70 | * Checks if the component associated with this entity exists 71 | * 72 | * @param {Entity} entity 73 | */ 74 | contains(entity: Entity): boolean { 75 | return this.get(entity) != null; 76 | } 77 | 78 | /// Fetches the component for this entity, if it exists 79 | get(entity: Entity): T | null { 80 | let [id, gen] = entity; 81 | const val = this.store[id]; 82 | return val && val[0] == gen ? val[1] : null; 83 | } 84 | 85 | /// Fetches the component, and initializing it if it is missing 86 | get_or(entity: Entity, init: () => T): T { 87 | let value = this.get(entity); 88 | 89 | if (!value) { 90 | value = init(); 91 | this.insert(entity, value); 92 | } 93 | 94 | return value; 95 | } 96 | 97 | /// Assigns component to an entity 98 | insert(entity: Entity, component: T) { 99 | let [id, gen] = entity; 100 | 101 | let length = this.store.length; 102 | if (length >= id) { 103 | this.store.fill(null, length, id); 104 | } 105 | 106 | this.store[id] = [gen, component]; 107 | } 108 | 109 | /** Check if the storage is empty */ 110 | is_empty(): boolean { 111 | for (const slot of this.store) if (slot) return false; 112 | return true; 113 | } 114 | 115 | /// Removes the component for this entity, if it exists 116 | remove(entity: Entity): T | null { 117 | const comp = this.get(entity); 118 | if (comp) { 119 | this.store[entity[0]] = null; 120 | } 121 | return comp; 122 | } 123 | 124 | /** 125 | * Takes the component associated with the `entity`, and passes it into the `func` callback 126 | * 127 | * @param {Entity} entity 128 | * @param {function} func 129 | */ 130 | take_with(entity: Entity, func: (component: T) => X): X | null { 131 | const component = this.remove(entity); 132 | return component ? func(component) : null; 133 | } 134 | 135 | /// Apply a function to the component when it exists 136 | with(entity: Entity, func: (component: T) => X): X | null { 137 | const component = this.get(entity); 138 | return component ? func(component) : null; 139 | } 140 | } 141 | 142 | /// The world maintains all of the entities, which have their components associated in storages 143 | /// 144 | /// # Implementation Notes 145 | /// 146 | /// This implementation consists of: 147 | /// 148 | /// - An array for storing entities 149 | /// - An array for storing a list of registered storages 150 | /// - An array for containing a list of free slots to allocate 151 | /// - An array for storing tags associated with an entity 152 | export class World { 153 | private entities_: Array; 154 | private storages: Array>; 155 | private tags_: Array; 156 | private free_slots: Array; 157 | 158 | constructor() { 159 | this.entities_ = new Array(); 160 | this.storages = new Array(); 161 | this.tags_ = new Array(); 162 | this.free_slots = new Array(); 163 | } 164 | 165 | /// The total capacity of the entity array 166 | get capacity(): number { 167 | return this.entities_.length; 168 | } 169 | 170 | /// The number of unallocated entity slots 171 | get free(): number { 172 | return this.free_slots.length; 173 | } 174 | 175 | /// The number of allocated entities 176 | get length(): number { 177 | return this.capacity - this.free; 178 | } 179 | 180 | /// Fetches tags associated with an entity 181 | /// 182 | /// Tags are essentially a dense set of small components 183 | tags(entity: Entity): any { 184 | return this.tags_[entity[0]]; 185 | } 186 | 187 | /// Iterates across entities in the world 188 | *entities(): IterableIterator { 189 | for (const entity of this.entities_.values()) { 190 | if (!(this.free_slots.indexOf(entity[0]) > -1)) yield entity; 191 | } 192 | } 193 | 194 | /// Create a new entity in the world 195 | /// 196 | /// Find the first available slot, and increment the generation. 197 | create_entity(): Entity { 198 | let slot = this.free_slots.pop(); 199 | 200 | if (slot) { 201 | var entity = this.entities_[slot]; 202 | entity[1] += 1; 203 | } else { 204 | var entity = entity_new(this.capacity, 0); 205 | this.entities_.push(entity); 206 | this.tags_.push(new Set()); 207 | } 208 | 209 | return entity; 210 | } 211 | 212 | /// Deletes an entity from the world 213 | /// 214 | /// Sets the `id` of the entity to `null`, thus marking its slot as unused. 215 | delete_entity(entity: Entity) { 216 | this.tags(entity).clear(); 217 | for (const storage of this.storages) { 218 | storage.remove(entity); 219 | } 220 | 221 | this.free_slots.push(entity[0]); 222 | } 223 | 224 | /// Adds a new tag to the given entity 225 | add_tag(entity: Entity, tag: any) { 226 | this.tags(entity).add(tag); 227 | } 228 | 229 | /// Returns `true` if this tag exists for the given entity 230 | contains_tag(entity: Entity, tag: any): boolean { 231 | return this.tags(entity).has(tag); 232 | } 233 | 234 | /// Deletes a tag from the given entity 235 | delete_tag(entity: Entity, tag: any) { 236 | this.tags(entity).delete(tag); 237 | } 238 | 239 | /// Registers a new component storage for our world 240 | /// 241 | /// This will be used to easily remove components when deleting an entity. 242 | register_storage(): Storage { 243 | let storage = new Storage(); 244 | this.storages.push(storage); 245 | return storage; 246 | } 247 | 248 | /// Unregisters an old component storage from our world 249 | unregister_storage(storage: Storage) { 250 | let matched = this.storages.indexOf(storage); 251 | if (matched) { 252 | swap_remove(this.storages, matched); 253 | } 254 | } 255 | } 256 | 257 | function swap_remove(array: Array, index: number): T | undefined { 258 | array[index] = array[array.length - 1]; 259 | return array.pop(); 260 | } 261 | 262 | /** A system registers events, and handles their execution. 263 | * 264 | * An executor must be provided for registering events onto. 265 | * 266 | */ 267 | export class System extends World { 268 | #executor: Executor; 269 | 270 | constructor(executor: Executor) { 271 | super(); 272 | 273 | this.#executor = executor; 274 | } 275 | 276 | /** Registers an event to be executed in the event loop */ 277 | register(event: T): void { 278 | this.#executor.wake(this, event); 279 | } 280 | 281 | /** Executs an event on the system */ 282 | run(_event: T): void {} 283 | } 284 | -------------------------------------------------------------------------------- /src/error.ts: -------------------------------------------------------------------------------- 1 | export class Error { 2 | reason: string; 3 | 4 | cause: Error | null = null; 5 | 6 | constructor(reason: string) { 7 | this.reason = reason; 8 | } 9 | 10 | context(why: string): Error { 11 | let error = new Error(why); 12 | error.cause = this; 13 | return error; 14 | } 15 | 16 | *chain(): IterableIterator { 17 | let current: Error | null = this; 18 | 19 | while (current != null) { 20 | yield current; 21 | current = current.cause; 22 | } 23 | } 24 | 25 | format(): string { 26 | let causes = this.chain(); 27 | 28 | let buffer: string = causes.next().value.reason; 29 | 30 | for (const error of causes) { 31 | buffer += `\n caused by: ` + error.reason; 32 | } 33 | 34 | return buffer + `\n`; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/events.ts: -------------------------------------------------------------------------------- 1 | import * as Window from './window.js'; 2 | 3 | import type { Ext } from './extension.js'; 4 | 5 | /** Type representing all possible events handled by the extension's system. */ 6 | export type ExtEvent = GenericCallback | ManagedWindow | CreateWindow | GlobalEventTag; 7 | 8 | /** Eevnt with generic callback */ 9 | export interface GenericCallback { 10 | tag: 1; 11 | callback: () => void; 12 | name?: string; 13 | } 14 | 15 | /** Event that handles a registered window */ 16 | export interface ManagedWindow { 17 | tag: 2; 18 | window: Window.ShellWindow; 19 | kind: Movement | Basic; 20 | } 21 | 22 | /** Event that registers a new window */ 23 | export interface CreateWindow { 24 | tag: 3; 25 | window: Meta.Window; 26 | } 27 | 28 | export interface GlobalEventTag { 29 | tag: 4; 30 | event: GlobalEvent; 31 | } 32 | 33 | export enum GlobalEvent { 34 | GtkShellChanged, 35 | GtkThemeChanged, 36 | MonitorsChanged, 37 | OverviewShown, 38 | OverviewHidden, 39 | } 40 | 41 | export interface Movement { 42 | tag: 1; 43 | } 44 | 45 | export interface Basic { 46 | tag: 2; 47 | event: WindowEvent; 48 | } 49 | 50 | /** The type of event triggered on a window */ 51 | export enum WindowEvent { 52 | Size, 53 | Workspace, 54 | Minimize, 55 | Maximize, 56 | Fullscreen, 57 | } 58 | 59 | export function global(event: GlobalEvent): GlobalEventTag { 60 | return { tag: 4, event }; 61 | } 62 | 63 | export function window_move(ext: Ext, window: Window.ShellWindow, rect: Rectangular): ManagedWindow { 64 | ext.movements.insert(window.entity, rect); 65 | return { tag: 2, window, kind: { tag: 1 } }; 66 | } 67 | 68 | /** Utility function for creating the an ExtEvent */ 69 | export function window_event(window: Window.ShellWindow, event: WindowEvent): ManagedWindow { 70 | return { tag: 2, window, kind: { tag: 2, event } }; 71 | } 72 | -------------------------------------------------------------------------------- /src/executor.ts: -------------------------------------------------------------------------------- 1 | import * as Ecs from './ecs.js'; 2 | import GLib from 'gi://GLib'; 3 | 4 | export interface Executor { 5 | wake>(system: S, event: T): void; 6 | } 7 | 8 | /** Glib-based event executor */ 9 | export class GLibExecutor implements Executor { 10 | #event_loop: SignalID | null = null; 11 | #events: Array = new Array(); 12 | 13 | /** Creates an idle_add signal that exists only for as long as there are events to process. 14 | * 15 | * - If the signal has already been created, the event will be added to the queue. 16 | * - The signal will continue executing for as long as there are events remaining in the queue. 17 | * - Events are handled within batches, yielding between each new set of events. 18 | */ 19 | wake>(system: S, event: T): void { 20 | this.#events.unshift(event); 21 | 22 | if (this.#event_loop) return; 23 | 24 | this.#event_loop = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { 25 | let event = this.#events.pop(); 26 | if (event) system.run(event); 27 | 28 | if (this.#events.length === 0) { 29 | this.#event_loop = null; 30 | return false; 31 | } 32 | 33 | return true; 34 | }); 35 | } 36 | } 37 | 38 | export class OnceExecutor> { 39 | #iterable: T; 40 | #signal: SignalID | null = null; 41 | 42 | constructor(iterable: T) { 43 | this.#iterable = iterable; 44 | } 45 | 46 | start(delay: number, apply: (v: X) => boolean, then?: () => void) { 47 | this.stop(); 48 | 49 | const iterator = this.#iterable[Symbol.iterator](); 50 | 51 | this.#signal = GLib.timeout_add(GLib.PRIORITY_DEFAULT, delay, () => { 52 | const next: X = iterator.next().value; 53 | 54 | if (typeof next === 'undefined') { 55 | if (then) 56 | GLib.timeout_add(GLib.PRIORITY_DEFAULT, delay, () => { 57 | then(); 58 | return false; 59 | }); 60 | 61 | return false; 62 | } 63 | 64 | return apply(next); 65 | }); 66 | } 67 | 68 | stop() { 69 | if (this.#signal !== null) GLib.source_remove(this.#signal); 70 | } 71 | } 72 | 73 | export class ChannelExecutor { 74 | #channel: Array = new Array(); 75 | 76 | #signal: null | number = null; 77 | 78 | clear() { 79 | this.#channel.splice(0); 80 | } 81 | 82 | get length(): number { 83 | return this.#channel.length; 84 | } 85 | 86 | send(v: X) { 87 | this.#channel.push(v); 88 | } 89 | 90 | start(delay: number, apply: (v: X) => boolean) { 91 | this.stop(); 92 | 93 | this.#signal = GLib.timeout_add(GLib.PRIORITY_DEFAULT, delay, () => { 94 | const e = this.#channel.shift(); 95 | 96 | return typeof e === 'undefined' ? true : apply(e); 97 | }); 98 | } 99 | 100 | stop() { 101 | if (this.#signal !== null) GLib.source_remove(this.#signal); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/floating_exceptions/src/config.ts: -------------------------------------------------------------------------------- 1 | ../../config.ts -------------------------------------------------------------------------------- /src/floating_exceptions/src/main.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/gjs --module 2 | 3 | import Gio from 'gi://Gio'; 4 | import GLib from 'gi://GLib'; 5 | import Gtk from 'gi://Gtk?version=3.0'; 6 | import Pango from 'gi://Pango'; 7 | 8 | /** The directory that this script is executed from. */ 9 | const SCRIPT_DIR = GLib.path_get_dirname(new Error().stack.split(':')[0].slice(1)); 10 | 11 | /** Add our directory so we can import modules from it. */ 12 | imports.searchPath.push(SCRIPT_DIR); 13 | 14 | import * as config from './config.js'; 15 | 16 | const WM_CLASS_ID = 'pop-shell-exceptions'; 17 | 18 | interface SelectWindow { 19 | tag: 0; 20 | } 21 | 22 | enum ViewNum { 23 | MainView = 0, 24 | Exceptions = 1, 25 | } 26 | 27 | interface SwitchTo { 28 | tag: 1; 29 | view: ViewNum; 30 | } 31 | 32 | interface ToggleException { 33 | tag: 2; 34 | wmclass: string | undefined; 35 | wmtitle: string | undefined; 36 | enable: boolean; 37 | } 38 | 39 | interface RemoveException { 40 | tag: 3; 41 | wmclass: string | undefined; 42 | wmtitle: string | undefined; 43 | } 44 | 45 | type Event = SelectWindow | SwitchTo | ToggleException | RemoveException; 46 | 47 | interface View { 48 | widget: any; 49 | 50 | callback: (event: Event) => void; 51 | } 52 | 53 | function exceptions_button(): any { 54 | let title = Gtk.Label.new('System Exceptions'); 55 | title.set_xalign(0); 56 | 57 | let description = Gtk.Label.new('Updated based on validated user reports.'); 58 | description.set_xalign(0); 59 | description.get_style_context().add_class('dim-label'); 60 | 61 | let icon = Gtk.Image.new_from_icon_name('go-next-symbolic', Gtk.IconSize.BUTTON); 62 | icon.set_hexpand(true); 63 | icon.set_halign(Gtk.Align.END); 64 | 65 | let layout = Gtk.Grid.new(); 66 | layout.set_row_spacing(4); 67 | layout.set_border_width(12); 68 | layout.attach(title, 0, 0, 1, 1); 69 | layout.attach(description, 0, 1, 1, 1); 70 | layout.attach(icon, 1, 0, 1, 2); 71 | 72 | let button = Gtk.Button.new(); 73 | button.relief = Gtk.ReliefStyle.NONE; 74 | button.add(layout); 75 | 76 | return button; 77 | } 78 | 79 | export class MainView implements View { 80 | widget: any; 81 | 82 | callback: (event: Event) => void = () => {}; 83 | 84 | private list: any; 85 | 86 | constructor() { 87 | let select = Gtk.Button.new_with_label('Select'); 88 | select.set_halign(Gtk.Align.CENTER); 89 | select.connect('clicked', () => this.callback({ tag: 0 })); 90 | select.set_margin_bottom(12); 91 | 92 | let exceptions = exceptions_button(); 93 | exceptions.connect('clicked', () => this.callback({ tag: 1, view: ViewNum.Exceptions })); 94 | 95 | this.list = Gtk.ListBox.new(); 96 | this.list.set_selection_mode(Gtk.SelectionMode.NONE); 97 | this.list.set_header_func(list_header_func); 98 | this.list.add(exceptions); 99 | 100 | let scroller = new Gtk.ScrolledWindow(); 101 | scroller.hscrollbar_policy = Gtk.PolicyType.NEVER; 102 | scroller.set_propagate_natural_width(true); 103 | scroller.set_propagate_natural_height(true); 104 | scroller.add(this.list); 105 | 106 | let list_frame = Gtk.Frame.new(null); 107 | list_frame.add(scroller); 108 | 109 | let desc = new Gtk.Label({ 110 | label: 'Add exceptions by selecting currently running applications and windows.', 111 | }); 112 | desc.set_line_wrap(true); 113 | desc.set_halign(Gtk.Align.CENTER); 114 | desc.set_justify(Gtk.Justification.CENTER); 115 | desc.set_max_width_chars(55); 116 | desc.set_margin_top(12); 117 | 118 | this.widget = Gtk.Box.new(Gtk.Orientation.VERTICAL, 24); 119 | this.widget.add(desc); 120 | this.widget.add(select); 121 | this.widget.add(list_frame); 122 | } 123 | 124 | add_rule(wmclass: string | undefined, wmtitle: string | undefined) { 125 | let label = Gtk.Label.new(wmtitle === undefined ? wmclass : `${wmclass} / ${wmtitle}`); 126 | label.set_xalign(0); 127 | label.set_hexpand(true); 128 | label.set_ellipsize(Pango.EllipsizeMode.END); 129 | 130 | let button = Gtk.Button.new_from_icon_name('edit-delete', Gtk.IconSize.BUTTON); 131 | button.set_valign(Gtk.Align.CENTER); 132 | 133 | let widget = Gtk.Box.new(Gtk.Orientation.HORIZONTAL, 24); 134 | widget.add(label); 135 | widget.add(button); 136 | widget.set_border_width(12); 137 | widget.set_margin_start(12); 138 | widget.show_all(); 139 | 140 | button.connect('clicked', () => { 141 | widget.destroy(); 142 | this.callback({ tag: 3, wmclass, wmtitle }); 143 | }); 144 | 145 | this.list.add(widget); 146 | } 147 | } 148 | 149 | export class ExceptionsView implements View { 150 | widget: any; 151 | callback: (event: Event) => void = () => {}; 152 | 153 | exceptions: any = Gtk.ListBox.new(); 154 | 155 | constructor() { 156 | let desc_title = Gtk.Label.new('System Exceptions'); 157 | desc_title.set_use_markup(true); 158 | desc_title.set_xalign(0); 159 | 160 | let desc_desc = Gtk.Label.new('Updated based on validated user reports.'); 161 | desc_desc.set_xalign(0); 162 | desc_desc.get_style_context().add_class('dim-label'); 163 | desc_desc.set_margin_bottom(6); 164 | 165 | let scroller = new Gtk.ScrolledWindow(); 166 | scroller.hscrollbar_policy = Gtk.PolicyType.NEVER; 167 | scroller.set_propagate_natural_width(true); 168 | scroller.set_propagate_natural_height(true); 169 | scroller.add(this.exceptions); 170 | 171 | let exceptions_frame = Gtk.Frame.new(null); 172 | exceptions_frame.add(scroller); 173 | 174 | this.exceptions.set_selection_mode(Gtk.SelectionMode.NONE); 175 | this.exceptions.set_header_func(list_header_func); 176 | 177 | this.widget = Gtk.Box.new(Gtk.Orientation.VERTICAL, 6); 178 | this.widget.add(desc_title); 179 | this.widget.add(desc_desc); 180 | this.widget.add(exceptions_frame); 181 | } 182 | 183 | add_rule(wmclass: string | undefined, wmtitle: string | undefined, enabled: boolean) { 184 | let label = Gtk.Label.new(wmtitle === undefined ? wmclass : `${wmclass} / ${wmtitle}`); 185 | label.set_xalign(0); 186 | label.set_hexpand(true); 187 | label.set_ellipsize(Pango.EllipsizeMode.END); 188 | 189 | let button = Gtk.Switch.new(); 190 | button.set_valign(Gtk.Align.CENTER); 191 | button.set_state(enabled); 192 | button.connect('notify::state', () => { 193 | this.callback({ tag: 2, wmclass, wmtitle, enable: button.get_state() }); 194 | }); 195 | 196 | let widget = Gtk.Box.new(Gtk.Orientation.HORIZONTAL, 24); 197 | widget.add(label); 198 | widget.add(button); 199 | widget.show_all(); 200 | widget.set_border_width(12); 201 | 202 | this.exceptions.add(widget); 203 | } 204 | } 205 | 206 | class App { 207 | main_view: MainView = new MainView(); 208 | exceptions_view: ExceptionsView = new ExceptionsView(); 209 | 210 | stack: any = Gtk.Stack.new(); 211 | window: any; 212 | config: config.Config = new config.Config(); 213 | 214 | constructor() { 215 | this.stack.set_border_width(16); 216 | this.stack.add(this.main_view.widget); 217 | this.stack.add(this.exceptions_view.widget); 218 | 219 | let back = Gtk.Button.new_from_icon_name('go-previous-symbolic', Gtk.IconSize.BUTTON); 220 | 221 | const TITLE = 'Floating Window Exceptions'; 222 | 223 | let win = new Gtk.Dialog({ use_header_bar: true }); 224 | let headerbar = win.get_header_bar(); 225 | headerbar.set_show_close_button(true); 226 | headerbar.set_title(TITLE); 227 | headerbar.pack_start(back); 228 | 229 | Gtk.Window.set_default_icon_name('application-default'); 230 | 231 | win.set_wmclass(WM_CLASS_ID, TITLE); 232 | 233 | win.set_default_size(550, 700); 234 | win.get_content_area().add(this.stack); 235 | win.show_all(); 236 | win.connect('delete-event', () => Gtk.main_quit()); 237 | 238 | back.hide(); 239 | 240 | this.config.reload(); 241 | 242 | for (const value of config.DEFAULT_FLOAT_RULES.values()) { 243 | let wmtitle = value.title ?? undefined; 244 | let wmclass = value.class ?? undefined; 245 | 246 | let disabled = this.config.rule_disabled({ class: wmclass, title: wmtitle }); 247 | this.exceptions_view.add_rule(wmclass, wmtitle, !disabled); 248 | } 249 | 250 | for (const value of Array.from(this.config.float)) { 251 | let wmtitle = value.title ?? undefined; 252 | let wmclass = value.class ?? undefined; 253 | if (!value.disabled) this.main_view.add_rule(wmclass, wmtitle); 254 | } 255 | 256 | let event_handler = (event: Event) => { 257 | switch (event.tag) { 258 | // SelectWindow 259 | case 0: 260 | println('SELECT'); 261 | Gtk.main_quit(); 262 | break; 263 | 264 | // SwitchTo 265 | case 1: 266 | switch (event.view) { 267 | case ViewNum.MainView: 268 | this.stack.set_visible_child(this.main_view.widget); 269 | back.hide(); 270 | break; 271 | case ViewNum.Exceptions: 272 | this.stack.set_visible_child(this.exceptions_view.widget); 273 | back.show(); 274 | break; 275 | } 276 | 277 | break; 278 | 279 | // ToggleException 280 | case 2: 281 | log(`toggling exception ${event.enable}`); 282 | this.config.toggle_system_exception(event.wmclass, event.wmtitle, !event.enable); 283 | println('MODIFIED'); 284 | break; 285 | 286 | // RemoveException 287 | case 3: 288 | log(`removing exception`); 289 | this.config.remove_user_exception(event.wmclass, event.wmtitle); 290 | println('MODIFIED'); 291 | break; 292 | } 293 | }; 294 | 295 | this.main_view.callback = event_handler; 296 | this.exceptions_view.callback = event_handler; 297 | back.connect('clicked', () => event_handler({ tag: 1, view: ViewNum.MainView })); 298 | } 299 | } 300 | 301 | function list_header_func(row: any, before: null | any) { 302 | if (before) { 303 | row.set_header(Gtk.Separator.new(Gtk.Orientation.HORIZONTAL)); 304 | } 305 | } 306 | 307 | /** We'll use stdout for printing events for the shell to handle */ 308 | const STDOUT = new Gio.DataOutputStream({ 309 | base_stream: new Gio.UnixOutputStream({ fd: 1 }), 310 | }); 311 | 312 | /** Utility function for printing a message to stdout with an added newline */ 313 | function println(message: string) { 314 | STDOUT.put_string(message + '\n', null); 315 | } 316 | 317 | /** Initialize GTK and start the application */ 318 | function main() { 319 | GLib.set_prgname(WM_CLASS_ID); 320 | GLib.set_application_name('Pop Shell Floating Window Exceptions'); 321 | 322 | Gtk.init(null); 323 | 324 | new App(); 325 | 326 | Gtk.main(); 327 | } 328 | 329 | main(); 330 | -------------------------------------------------------------------------------- /src/floating_exceptions/src/mod.d.ts: -------------------------------------------------------------------------------- 1 | declare const log: (arg: string) => void, imports: any, _: (arg: string) => string; 2 | 3 | declare module 'gi://*' { 4 | let data: any; 5 | export default data; 6 | } 7 | 8 | declare module 'gi://Gtk?version=3.0' { 9 | let Gtk: any; 10 | export default Gtk; 11 | } 12 | -------------------------------------------------------------------------------- /src/floating_exceptions/src/utils.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pop-os/shell/b3fc4253dc29b30fb52ac5eef5d3af643a46d18c/src/floating_exceptions/src/utils.ts -------------------------------------------------------------------------------- /src/floating_exceptions/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": true, 3 | "compilerOptions": { 4 | "target": "es2015", 5 | // "strict": true, 6 | "outDir": "../../target/floating_exceptions", 7 | "forceConsistentCasingInFileNames": true, 8 | "downlevelIteration": true, 9 | "lib": ["es2015"], 10 | "pretty": true, 11 | "removeComments": true, 12 | "incremental": true 13 | }, 14 | "include": ["src/*.ts"] 15 | } 16 | -------------------------------------------------------------------------------- /src/focus.ts: -------------------------------------------------------------------------------- 1 | import * as Geom from './geom.js'; 2 | 3 | import type { ShellWindow } from './window.js'; 4 | import type { Ext } from './extension.js'; 5 | 6 | export enum FocusPosition { 7 | TopLeft = 'Top Left', 8 | TopRight = 'Top Right', 9 | BottomLeft = 'Bottom Left', 10 | BottomRight = 'Bottom Right', 11 | Center = 'Center', 12 | } 13 | 14 | export class FocusSelector { 15 | select( 16 | ext: Ext, 17 | direction: (a: ShellWindow, b: Array) => Array, 18 | window: ShellWindow | null, 19 | ): ShellWindow | null { 20 | window = window ?? ext.focus_window(); 21 | if (window) { 22 | let window_list = ext.active_window_list(); 23 | return select(direction, window, window_list); 24 | } 25 | 26 | return null; 27 | } 28 | 29 | down(ext: Ext, window: ShellWindow | null): ShellWindow | null { 30 | return this.select(ext, window_down, window); 31 | } 32 | 33 | left(ext: Ext, window: ShellWindow | null): ShellWindow | null { 34 | return this.select(ext, window_left, window); 35 | } 36 | 37 | right(ext: Ext, window: ShellWindow | null): ShellWindow | null { 38 | return this.select(ext, window_right, window); 39 | } 40 | 41 | up(ext: Ext, window: ShellWindow | null): ShellWindow | null { 42 | return this.select(ext, window_up, window); 43 | } 44 | } 45 | 46 | function select( 47 | windows: (a: ShellWindow, b: Array) => Array, 48 | focused: ShellWindow, 49 | window_list: Array, 50 | ): ShellWindow | null { 51 | const array = windows(focused, window_list); 52 | return array.length > 0 ? array[0] : null; 53 | } 54 | 55 | function window_down(focused: ShellWindow, windows: Array) { 56 | return windows 57 | .filter((win) => !win.meta.minimized && win.meta.get_frame_rect().y > focused.meta.get_frame_rect().y) 58 | .sort((a, b) => Geom.downward_distance(a.meta, focused.meta) - Geom.downward_distance(b.meta, focused.meta)); 59 | } 60 | 61 | function window_left(focused: ShellWindow, windows: Array) { 62 | return windows 63 | .filter((win) => !win.meta.minimized && win.meta.get_frame_rect().x < focused.meta.get_frame_rect().x) 64 | .sort((a, b) => Geom.leftward_distance(a.meta, focused.meta) - Geom.leftward_distance(b.meta, focused.meta)); 65 | } 66 | 67 | function window_right(focused: ShellWindow, windows: Array) { 68 | return windows 69 | .filter((win) => !win.meta.minimized && win.meta.get_frame_rect().x > focused.meta.get_frame_rect().x) 70 | .sort((a, b) => Geom.rightward_distance(a.meta, focused.meta) - Geom.rightward_distance(b.meta, focused.meta)); 71 | } 72 | 73 | function window_up(focused: ShellWindow, windows: Array) { 74 | return windows 75 | .filter((win) => !win.meta.minimized && win.meta.get_frame_rect().y < focused.meta.get_frame_rect().y) 76 | .sort((a, b) => Geom.upward_distance(a.meta, focused.meta) - Geom.upward_distance(b.meta, focused.meta)); 77 | } 78 | -------------------------------------------------------------------------------- /src/fork.ts: -------------------------------------------------------------------------------- 1 | import type { Forest } from './forest.js'; 2 | import type { Entity } from './ecs.js'; 3 | import type { Ext } from './extension.js'; 4 | import type { Rectangle } from './rectangle.js'; 5 | import type { Node } from './node.js'; 6 | 7 | import * as Ecs from './ecs.js'; 8 | import * as Lib from './lib.js'; 9 | import * as node from './node.js'; 10 | import * as Rect from './rectangle.js'; 11 | import { ShellWindow } from './window.js'; 12 | 13 | const XPOS = 0; 14 | const YPOS = 1; 15 | const WIDTH = 2; 16 | const HEIGHT = 3; 17 | 18 | /** A tiling fork contains two children nodes. 19 | * 20 | * These nodes may either be windows, or sub-forks. 21 | */ 22 | export class Fork { 23 | left: Node; 24 | right: Node | null; 25 | area: Rectangle; 26 | entity: Entity; 27 | on_primary_display: boolean; 28 | workspace: number; 29 | length_left: number; 30 | prev_length_left: number; 31 | prev_ratio: number = 0.5; 32 | monitor: number; 33 | minimum_ratio: number = 0.1; 34 | orientation: Lib.Orientation = Lib.Orientation.HORIZONTAL; 35 | 36 | orientation_changed: boolean = false; 37 | is_toplevel: boolean = false; 38 | 39 | smart_gapped: boolean = false; 40 | 41 | /** Tracks toggle count so that we may swap branches when toggled twice */ 42 | private n_toggled: number = 0; 43 | 44 | constructor( 45 | entity: Entity, 46 | left: Node, 47 | right: Node | null, 48 | area: Rectangle, 49 | workspace: WorkspaceID, 50 | monitor: MonitorID, 51 | orient: Lib.Orientation, 52 | ) { 53 | this.on_primary_display = global.display.get_primary_monitor() === monitor; 54 | this.area = area; 55 | this.left = left; 56 | this.right = right; 57 | this.workspace = workspace; 58 | this.length_left = orient === Lib.Orientation.HORIZONTAL ? this.area.width / 2 : this.area.height / 2; 59 | this.prev_length_left = this.length_left; 60 | this.entity = entity; 61 | this.orientation = orient; 62 | this.monitor = monitor; 63 | } 64 | 65 | /** The calculated left area of this fork */ 66 | area_of_left(ext: Ext): Rect.Rectangle { 67 | return new Rect.Rectangle( 68 | this.is_horizontal() 69 | ? [this.area.x, this.area.y, this.length_left - ext.gap_inner_half, this.area.height] 70 | : [this.area.x, this.area.y, this.area.width, this.length_left - ext.gap_inner_half], 71 | ); 72 | } 73 | 74 | /** The calculated right area of this fork */ 75 | area_of_right(ext: Ext): Rect.Rectangle { 76 | let area: [number, number, number, number]; 77 | 78 | if (this.is_horizontal()) { 79 | const width = this.area.width - this.length_left + ext.gap_inner; 80 | area = [width, this.area.y, this.area.width - width, this.area.height]; 81 | } else { 82 | const height = this.area.height - this.length_left + ext.gap_inner; 83 | area = [this.area.x, height, this.area.width, this.area.height - height]; 84 | } 85 | 86 | return new Rect.Rectangle(area); 87 | } 88 | 89 | depth(): number { 90 | return this.is_horizontal() ? this.area.height : this.area.width; 91 | } 92 | 93 | find_branch(entity: Entity): Node | null { 94 | const locate = (branch: Node): Node | null => { 95 | switch (branch.inner.kind) { 96 | case 2: 97 | if (Ecs.entity_eq(branch.inner.entity, entity)) { 98 | return branch; 99 | } 100 | 101 | break; 102 | case 3: 103 | for (const e of branch.inner.entities) { 104 | if (Ecs.entity_eq(e, entity)) { 105 | return branch; 106 | } 107 | } 108 | } 109 | 110 | return null; 111 | }; 112 | 113 | const node = locate(this.left); 114 | if (node) return node; 115 | 116 | return this.right ? locate(this.right) : null; 117 | } 118 | 119 | /** If this fork has a horizontal orientation */ 120 | is_horizontal(): boolean { 121 | return Lib.Orientation.HORIZONTAL == this.orientation; 122 | } 123 | 124 | length(): number { 125 | return this.is_horizontal() ? this.area.width : this.area.height; 126 | } 127 | 128 | /** Replaces the association of a window in a fork with another */ 129 | replace_window(ext: Ext, a: ShellWindow, b: ShellWindow): null | (() => void) { 130 | let closure = null; 131 | 132 | let check_right = () => { 133 | if (this.right) { 134 | const inner = this.right.inner; 135 | if (inner.kind === 2) { 136 | closure = () => { 137 | inner.entity = b.entity; 138 | }; 139 | } else if (inner.kind === 3) { 140 | const idx = node.stack_find(inner, a.entity); 141 | if (idx === null) { 142 | closure = null; 143 | return; 144 | } 145 | 146 | closure = () => { 147 | node.stack_replace(ext, inner, b); 148 | inner.entities[idx] = b.entity; 149 | }; 150 | } 151 | } 152 | }; 153 | 154 | switch (this.left.inner.kind) { 155 | case 1: 156 | check_right(); 157 | break; 158 | case 2: 159 | const inner = this.left.inner; 160 | if (Ecs.entity_eq(inner.entity, a.entity)) { 161 | closure = () => { 162 | inner.entity = b.entity; 163 | }; 164 | } else { 165 | check_right(); 166 | } 167 | 168 | break; 169 | case 3: 170 | const inner_s = this.left.inner as node.NodeStack; 171 | let idx = node.stack_find(inner_s, a.entity); 172 | if (idx !== null) { 173 | const id = idx; 174 | closure = () => { 175 | node.stack_replace(ext, inner_s, b); 176 | inner_s.entities[id] = b.entity; 177 | }; 178 | } else { 179 | check_right(); 180 | } 181 | } 182 | 183 | return closure; 184 | } 185 | 186 | /** Sets a new area for this fork */ 187 | set_area(area: Rectangle): Rectangle { 188 | this.area = area; 189 | return this.area; 190 | } 191 | 192 | /** Sets the ratio of this fork 193 | * 194 | * Ensures that the ratio is never smaller or larger than the constraints. 195 | */ 196 | set_ratio(left_length: number): Fork { 197 | const fork_len = this.is_horizontal() ? this.area.width : this.area.height; 198 | const clamped = Math.round(Math.max(256, Math.min(fork_len - 256, left_length))); 199 | this.prev_length_left = clamped; 200 | this.length_left = clamped; 201 | return this; 202 | } 203 | 204 | /** Defines this fork as a top level fork, and records it in the forest */ 205 | set_toplevel(tiler: Forest, entity: Entity, string: string, id: [number, number]): Fork { 206 | this.is_toplevel = true; 207 | tiler.toplevel.set(string, [entity, id]); 208 | return this; 209 | } 210 | 211 | /** Calculates the future arrangement of windows in this fork */ 212 | measure(tiler: Forest, ext: Ext, area: Rectangle, record: (win: Entity, parent: Entity, area: Rectangle) => void) { 213 | let ratio = null; 214 | 215 | let manually_moved = ext.grab_op !== null || ext.tiler.resizing_window; 216 | 217 | if (!this.is_toplevel) { 218 | if (this.orientation_changed) { 219 | this.orientation_changed = false; 220 | ratio = this.length_left / this.depth(); 221 | } else { 222 | ratio = this.length_left / this.length(); 223 | } 224 | 225 | this.area = this.set_area(area.clone()); 226 | } else if (this.orientation_changed) { 227 | this.orientation_changed = false; 228 | ratio = this.length_left / this.depth(); 229 | } 230 | 231 | if (ratio) { 232 | this.length_left = Math.round(ratio * this.length()); 233 | if (manually_moved) this.prev_ratio = ratio; 234 | } else if (manually_moved) { 235 | this.prev_ratio = this.length_left / this.length(); 236 | } 237 | 238 | if (this.right) { 239 | const [l, p, startpos] = this.is_horizontal() ? [WIDTH, XPOS, this.area.x] : [HEIGHT, YPOS, this.area.y]; 240 | 241 | let region = this.area.clone(); 242 | 243 | const half = this.area.array[l] / 2; 244 | 245 | let length; 246 | if (this.length_left > half - 32 && this.length_left < half + 32) { 247 | length = half; 248 | } else { 249 | const diff = (startpos + this.length_left) % 32; 250 | length = this.length_left - diff + (diff > 16 ? 32 : 0); 251 | if (length == 0) length = 32; 252 | } 253 | 254 | region.array[l] = length - ext.gap_inner_half; 255 | 256 | this.left.measure(tiler, ext, this.entity, region, record); 257 | 258 | region.array[p] = region.array[p] + length + ext.gap_inner_half; 259 | region.array[l] = this.area.array[l] - length - ext.gap_inner_half; 260 | 261 | this.right.measure(tiler, ext, this.entity, region, record); 262 | } else { 263 | this.left.measure(tiler, ext, this.entity, this.area, record); 264 | } 265 | } 266 | 267 | migrate(ext: Ext, forest: Forest, area: Rectangle, monitor: number, workspace: number) { 268 | if (ext.auto_tiler && this.is_toplevel) { 269 | const primary = global.display.get_primary_monitor() === monitor; 270 | 271 | this.monitor = monitor; 272 | this.workspace = workspace; 273 | this.on_primary_display = primary; 274 | 275 | let blocked = new Array(); 276 | 277 | forest.toplevel.set(forest.string_reps.get(this.entity) as string, [this.entity, [monitor, workspace]]); 278 | 279 | for (const child of forest.iter(this.entity)) { 280 | switch (child.inner.kind) { 281 | case 1: 282 | const cfork = forest.forks.get(child.inner.entity); 283 | if (!cfork) continue; 284 | cfork.workspace = workspace; 285 | cfork.monitor = monitor; 286 | cfork.on_primary_display = primary; 287 | break; 288 | case 2: 289 | let window = ext.windows.get(child.inner.entity); 290 | if (window) { 291 | ext.size_signals_block(window); 292 | window.reassignment = false; 293 | window.known_workspace = workspace; 294 | window.meta.change_workspace_by_index(workspace, true); 295 | ext.monitors.insert(window.entity, [monitor, workspace]); 296 | blocked.push(window); 297 | } 298 | break; 299 | case 3: 300 | for (const entity of child.inner.entities) { 301 | let stack = ext.auto_tiler.forest.stacks.get(child.inner.idx); 302 | if (stack) { 303 | stack.workspace = workspace; 304 | } 305 | 306 | let window = ext.windows.get(entity); 307 | 308 | if (window) { 309 | ext.size_signals_block(window); 310 | window.known_workspace = workspace; 311 | window.meta.change_workspace_by_index(workspace, true); 312 | ext.monitors.insert(window.entity, [monitor, workspace]); 313 | blocked.push(window); 314 | } 315 | } 316 | } 317 | } 318 | 319 | area.x += ext.gap_outer; 320 | area.y += ext.gap_outer; 321 | area.width -= ext.gap_outer * 2; 322 | area.height -= ext.gap_outer * 2; 323 | 324 | this.set_area(area.clone()); 325 | this.measure(forest, ext, area, forest.on_record()); 326 | forest.arrange(ext, workspace, true); 327 | 328 | for (const window of blocked) { 329 | ext.size_signals_unblock(window); 330 | } 331 | } 332 | } 333 | 334 | rebalance_orientation() { 335 | this.set_orientation( 336 | this.area.height > this.area.width ? Lib.Orientation.VERTICAL : Lib.Orientation.HORIZONTAL, 337 | ); 338 | } 339 | 340 | set_orientation(o: Lib.Orientation) { 341 | if (o !== this.orientation) { 342 | this.orientation = o; 343 | this.orientation_changed = true; 344 | } 345 | } 346 | 347 | /** Swaps the left branch with the right branch, if there is a right branch */ 348 | swap_branches() { 349 | if (this.right) { 350 | const temp = this.left; 351 | this.left = this.right; 352 | this.right = temp; 353 | } 354 | } 355 | 356 | /** Toggles the orientation of this fork */ 357 | toggle_orientation() { 358 | this.orientation = 359 | Lib.Orientation.HORIZONTAL === this.orientation ? Lib.Orientation.VERTICAL : Lib.Orientation.HORIZONTAL; 360 | 361 | this.orientation_changed = true; 362 | if (this.n_toggled === 1) { 363 | if (this.right) { 364 | const tmp = this.right; 365 | this.right = this.left; 366 | this.left = tmp; 367 | } 368 | this.n_toggled = 0; 369 | } else { 370 | this.n_toggled += 1; 371 | } 372 | } 373 | } 374 | -------------------------------------------------------------------------------- /src/geom.ts: -------------------------------------------------------------------------------- 1 | import type { Ext } from './extension.js'; 2 | 3 | export enum Side { 4 | LEFT, 5 | TOP, 6 | RIGHT, 7 | BOTTOM, 8 | CENTER, 9 | } 10 | 11 | export function xend(rect: Rectangular): number { 12 | return rect.x + rect.width; 13 | } 14 | 15 | export function xcenter(rect: Rectangular): number { 16 | return rect.x + rect.width / 2; 17 | } 18 | 19 | export function yend(rect: Rectangular): number { 20 | return rect.y + rect.height; 21 | } 22 | 23 | export function ycenter(rect: Rectangular): number { 24 | return rect.y + rect.height / 2; 25 | } 26 | 27 | export function center(rect: Rectangular): [number, number] { 28 | return [xcenter(rect), ycenter(rect)]; 29 | } 30 | 31 | export function north(rect: Rectangular): [number, number] { 32 | return [xcenter(rect), rect.y]; 33 | } 34 | 35 | export function east(rect: Rectangular): [number, number] { 36 | return [xend(rect), ycenter(rect)]; 37 | } 38 | 39 | export function south(rect: Rectangular): [number, number] { 40 | return [xcenter(rect), yend(rect)]; 41 | } 42 | 43 | export function west(rect: Rectangular): [number, number] { 44 | return [rect.x, ycenter(rect)]; 45 | } 46 | 47 | export function distance([ax, ay]: [number, number], [bx, by]: [number, number]): number { 48 | return Math.sqrt(Math.pow(bx - ax, 2) + Math.pow(by - ay, 2)); 49 | } 50 | 51 | export function directional_distance( 52 | a: Rectangular, 53 | b: Rectangular, 54 | fn_a: (rect: Rectangular) => [number, number], 55 | fn_b: (rect: Rectangular) => [number, number], 56 | ) { 57 | return distance(fn_a(a), fn_b(b)); 58 | } 59 | 60 | export function window_distance(win_a: Meta.Window, win_b: Meta.Window) { 61 | return directional_distance(win_a.get_frame_rect(), win_b.get_frame_rect(), center, center); 62 | } 63 | 64 | export function upward_distance(win_a: Meta.Window, win_b: Meta.Window) { 65 | return directional_distance(win_a.get_frame_rect(), win_b.get_frame_rect(), south, north); 66 | } 67 | 68 | export function rightward_distance(win_a: Meta.Window, win_b: Meta.Window) { 69 | return directional_distance(win_a.get_frame_rect(), win_b.get_frame_rect(), west, east); 70 | } 71 | 72 | export function downward_distance(win_a: Meta.Window, win_b: Meta.Window) { 73 | return directional_distance(win_a.get_frame_rect(), win_b.get_frame_rect(), north, south); 74 | } 75 | 76 | export function leftward_distance(win_a: Meta.Window, win_b: Meta.Window) { 77 | return directional_distance(win_a.get_frame_rect(), win_b.get_frame_rect(), east, west); 78 | } 79 | 80 | export function nearest_side(ext: Ext, origin: [number, number], rect: Rectangular): [number, Side] { 81 | const left = west(rect), 82 | top = north(rect), 83 | right = east(rect), 84 | bottom = south(rect), 85 | ctr = center(rect); 86 | 87 | const left_distance = distance(origin, left), 88 | top_distance = distance(origin, top), 89 | right_distance = distance(origin, right), 90 | bottom_distance = distance(origin, bottom), 91 | center_distance = distance(origin, ctr); 92 | 93 | let nearest: [number, Side] = 94 | left_distance < right_distance ? [left_distance, Side.LEFT] : [right_distance, Side.RIGHT]; 95 | 96 | if (top_distance < nearest[0]) nearest = [top_distance, Side.TOP]; 97 | if (bottom_distance < nearest[0]) nearest = [bottom_distance, Side.BOTTOM]; 98 | if (ext.settings.stacking_with_mouse() && center_distance < nearest[0]) nearest = [center_distance, Side.CENTER]; 99 | 100 | return nearest; 101 | } 102 | 103 | export function shortest_side(origin: [number, number], rect: Rectangular): number { 104 | let shortest = distance(origin, west(rect)); 105 | shortest = Math.min(shortest, distance(origin, north(rect))); 106 | shortest = Math.min(shortest, distance(origin, east(rect))); 107 | return Math.min(shortest, distance(origin, south(rect))); 108 | } 109 | -------------------------------------------------------------------------------- /src/grab_op.ts: -------------------------------------------------------------------------------- 1 | import * as Movement from './movement.js'; 2 | 3 | import type { Entity } from './ecs.js'; 4 | import type { Rectangle } from './rectangle.js'; 5 | 6 | export class GrabOp { 7 | entity: Entity; 8 | rect: Rectangle; 9 | 10 | constructor(entity: Entity, rect: Rectangle) { 11 | this.entity = entity; 12 | this.rect = rect; 13 | } 14 | 15 | operation(change: Rectangle): Movement.Movement { 16 | return Movement.calculate(this.rect, change); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/keybindings.ts: -------------------------------------------------------------------------------- 1 | import type { Ext } from './extension.js'; 2 | 3 | import { wm } from 'resource:///org/gnome/shell/ui/main.js'; 4 | import Shell from 'gi://Shell'; 5 | import Meta from 'gi://Meta'; 6 | 7 | export class Keybindings { 8 | global: Object; 9 | window_focus: Object; 10 | 11 | private ext: Ext; 12 | 13 | constructor(ext: Ext) { 14 | this.ext = ext; 15 | this.global = { 16 | 'activate-launcher': () => ext.window_search.open(ext), 17 | 'tile-enter': () => ext.tiler.enter(ext), 18 | }; 19 | 20 | this.window_focus = { 21 | 'focus-left': () => ext.focus_left(), 22 | 23 | 'focus-down': () => ext.focus_down(), 24 | 25 | 'focus-up': () => ext.focus_up(), 26 | 27 | 'focus-right': () => ext.focus_right(), 28 | 29 | 'tile-orientation': () => { 30 | const win = ext.focus_window(); 31 | if (win && ext.auto_tiler) { 32 | ext.auto_tiler.toggle_orientation(ext, win); 33 | ext.register_fn(() => win.activate(true)); 34 | } 35 | }, 36 | 37 | 'toggle-floating': () => ext.auto_tiler?.toggle_floating(ext), 38 | 39 | 'toggle-tiling': () => ext.toggle_tiling(), 40 | 41 | 'toggle-stacking-global': () => ext.auto_tiler?.toggle_stacking(ext), 42 | 43 | 'tile-move-left-global': () => ext.tiler.move_left(ext, ext.focus_window()?.entity), 44 | 45 | 'tile-move-down-global': () => ext.tiler.move_down(ext, ext.focus_window()?.entity), 46 | 47 | 'tile-move-up-global': () => ext.tiler.move_up(ext, ext.focus_window()?.entity), 48 | 49 | 'tile-move-right-global': () => ext.tiler.move_right(ext, ext.focus_window()?.entity), 50 | 51 | 'pop-monitor-left': () => ext.move_monitor(Meta.DisplayDirection.LEFT), 52 | 53 | 'pop-monitor-right': () => ext.move_monitor(Meta.DisplayDirection.RIGHT), 54 | 55 | 'pop-monitor-up': () => ext.move_monitor(Meta.DisplayDirection.UP), 56 | 57 | 'pop-monitor-down': () => ext.move_monitor(Meta.DisplayDirection.DOWN), 58 | 59 | 'pop-workspace-up': () => ext.move_workspace(Meta.DisplayDirection.UP), 60 | 61 | 'pop-workspace-down': () => ext.move_workspace(Meta.DisplayDirection.DOWN), 62 | }; 63 | } 64 | 65 | enable(keybindings: any) { 66 | for (const name in keybindings) { 67 | wm.addKeybinding( 68 | name, 69 | this.ext.settings.ext, 70 | Meta.KeyBindingFlags.NONE, 71 | Shell.ActionMode.NORMAL, 72 | keybindings[name], 73 | ); 74 | } 75 | 76 | return this; 77 | } 78 | 79 | disable(keybindings: Object) { 80 | for (const name in keybindings) { 81 | wm.removeKeybinding(name); 82 | } 83 | 84 | return this; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/launcher.ts: -------------------------------------------------------------------------------- 1 | import * as search from './search.js'; 2 | import * as utils from './utils.js'; 3 | import * as arena from './arena.js'; 4 | import * as log from './log.js'; 5 | import * as service from './launcher_service.js'; 6 | import * as context from './context.js'; 7 | 8 | import type { Ext } from './extension.js'; 9 | import type { ShellWindow } from './window.js'; 10 | import type { JsonIPC } from './launcher_service.js'; 11 | 12 | import Clutter from 'gi://Clutter'; 13 | import GLib from 'gi://GLib'; 14 | import Meta from 'gi://Meta'; 15 | import Gio from 'gi://Gio'; 16 | import Shell from 'gi://Shell'; 17 | import St from 'gi://St'; 18 | 19 | const app_sys = Shell.AppSystem.get_default(); 20 | 21 | const Clipboard = St.Clipboard.get_default(); 22 | const CLIPBOARD_TYPE = St.ClipboardType.CLIPBOARD; 23 | 24 | interface SearchOption { 25 | result: JsonIPC.SearchResult; 26 | menu: St.Widget; 27 | } 28 | 29 | export class Launcher extends search.Search { 30 | ext: Ext; 31 | options: Map = new Map(); 32 | options_array: Array = new Array(); 33 | windows: arena.Arena = new arena.Arena(); 34 | service: null | service.LauncherService = null; 35 | append_id: null | number = null; 36 | active_menu: null | any = null; 37 | opened: boolean = false; 38 | 39 | constructor(ext: Ext) { 40 | super(); 41 | 42 | this.ext = ext; 43 | 44 | this.dialog.dialogLayout._dialog.y_align = Clutter.ActorAlign.START; 45 | this.dialog.dialogLayout._dialog.x_align = Clutter.ActorAlign.START; 46 | this.dialog.dialogLayout.y = 48; 47 | 48 | this.cancel = () => { 49 | ext.overlay.visible = false; 50 | this.stop_services(ext); 51 | this.opened = false; 52 | }; 53 | 54 | this.search = (pat: string) => { 55 | if (this.service !== null) { 56 | this.service.query(pat); 57 | } 58 | }; 59 | 60 | this.select = (id: number) => { 61 | ext.overlay.visible = false; 62 | 63 | if (id >= this.options.size) return; 64 | 65 | const option = this.options_array[id]; 66 | if (option && option.result.window) { 67 | const win = this.ext.windows.get(option.result.window); 68 | if (!win) return; 69 | 70 | if (win.workspace_id() == ext.active_workspace()) { 71 | const { x, y, width, height } = win.rect(); 72 | ext.overlay.x = x; 73 | ext.overlay.y = y; 74 | ext.overlay.width = width; 75 | ext.overlay.height = height; 76 | ext.overlay.visible = true; 77 | } 78 | } 79 | }; 80 | 81 | this.activate_id = (id: number) => { 82 | ext.overlay.visible = false; 83 | 84 | const selected = this.options_array[id]; 85 | 86 | if (selected) { 87 | this.service?.activate(selected.result.id); 88 | } 89 | }; 90 | 91 | this.complete = () => { 92 | const option = this.options_array[this.active_id]; 93 | if (option) { 94 | this.service?.complete(option.result.id); 95 | } 96 | }; 97 | 98 | this.quit = (id: number) => { 99 | const option = this.options_array[id]; 100 | if (option) { 101 | this.service?.quit(option.result.id); 102 | } 103 | }; 104 | 105 | this.copy = (id: number) => { 106 | const option = this.options_array[id]; 107 | if (!option) return; 108 | if (option.result.description) { 109 | Clipboard.set_text(CLIPBOARD_TYPE, option.result.description); 110 | } else if (option.result.name) { 111 | Clipboard.set_text(CLIPBOARD_TYPE, option.result.name); 112 | } 113 | }; 114 | } 115 | 116 | on_response(response: JsonIPC.Response) { 117 | if ('Close' === response) { 118 | this.close(); 119 | } else if ('Update' in response) { 120 | this.clear(); 121 | 122 | if (this.append_id !== null) { 123 | GLib.source_remove(this.append_id); 124 | this.append_id = null; 125 | } 126 | 127 | if (response.Update.length === 0) { 128 | this.cleanup(); 129 | return; 130 | } 131 | 132 | this.append_id = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { 133 | const item = response.Update.shift(); 134 | if (item) { 135 | try { 136 | const button = new search.SearchOption( 137 | item.name, 138 | item.description, 139 | item.category_icon ? item.category_icon : null, 140 | item.icon ? item.icon : null, 141 | this.icon_size(), 142 | null, 143 | null, 144 | ); 145 | 146 | const menu = context.addMenu(button.widget, (menu) => { 147 | if (this.active_menu) { 148 | this.active_menu.actor.hide(); 149 | } 150 | 151 | this.active_menu = menu; 152 | 153 | this.service?.context(item.id); 154 | }); 155 | 156 | this.append_search_option(button); 157 | const result = { result: item, menu }; 158 | this.options.set(item.id, result); 159 | this.options_array.push(result); 160 | } catch (error) { 161 | log.error(`failed to create SearchOption: ${error}`); 162 | } 163 | } 164 | 165 | if (response.Update.length === 0) { 166 | this.append_id = null; 167 | return false; 168 | } 169 | 170 | return true; 171 | }); 172 | } else if ('Fill' in response) { 173 | this.set_text(response.Fill); 174 | } else if ('DesktopEntry' in response) { 175 | this.launch_desktop_entry(response.DesktopEntry); 176 | this.close(); 177 | } else if ('Context' in response) { 178 | const { id, options } = response.Context; 179 | 180 | const option = this.options.get(id); 181 | if (option) { 182 | (option.menu as any).removeAll(); 183 | for (const opt of options) { 184 | context.addContext(option.menu, opt.name, () => { 185 | this.service?.activate_context(id, opt.id); 186 | }); 187 | 188 | (option.menu as any).toggle(); 189 | } 190 | } else { 191 | log.error(`did not find id: ${id}`); 192 | } 193 | } else { 194 | log.error(`unknown response: ${JSON.stringify(response)}`); 195 | } 196 | } 197 | 198 | clear() { 199 | this.options.clear(); 200 | this.options_array.splice(0); 201 | super.clear(); 202 | } 203 | 204 | launch_desktop_app(app: any, path: string) { 205 | try { 206 | app.launch([], null); 207 | } catch (why) { 208 | log.error(`${path}: could not launch by app info: ${why}`); 209 | } 210 | } 211 | 212 | launch_desktop_entry(entry: JsonIPC.DesktopEntry) { 213 | const basename = (name: string): string => { 214 | return name.substr(name.indexOf('/applications/') + 14).replace('/', '-'); 215 | }; 216 | 217 | const desktop_entry_id = basename(entry.path); 218 | 219 | const gpuPref = entry.gpu_preference === 'Default' ? Shell.AppLaunchGpu.DEFAULT : Shell.AppLaunchGpu.DISCRETE; 220 | 221 | log.debug(`launching desktop entry: ${desktop_entry_id}`); 222 | 223 | let app = app_sys.lookup_desktop_wmclass(desktop_entry_id); 224 | 225 | if (!app) { 226 | app = app_sys.lookup_app(desktop_entry_id); 227 | } 228 | 229 | if (!app) { 230 | log.error(`GNOME Shell cannot find desktop entry for ${desktop_entry_id}`); 231 | log.error(`pop-launcher will use Gio.DesktopAppInfo instead`); 232 | 233 | const dapp = Gio.DesktopAppInfo.new_from_filename(entry.path); 234 | 235 | if (!dapp) { 236 | log.error(`could not find desktop entry for ${entry.path}`); 237 | return; 238 | } 239 | 240 | this.launch_desktop_app(dapp, entry.path); 241 | return; 242 | } 243 | 244 | const info = app.get_app_info(); 245 | 246 | if (!info) { 247 | log.error(`cannot find app info for ${desktop_entry_id}`); 248 | return; 249 | } 250 | 251 | try { 252 | app.launch(0, -1, gpuPref); 253 | } catch (why) { 254 | log.error(`failed to launch application: ${why}`); 255 | return; 256 | } 257 | 258 | if (info.get_executable() === 'gnome-control-center') { 259 | app = app_sys.lookup_app('gnome-control-center.desktop'); 260 | 261 | if (!app) return; 262 | 263 | app.activate(); 264 | 265 | const window: Meta.Window = app.get_windows()[0]; 266 | 267 | if (window) { 268 | window.get_workspace().activate_with_focus(window, global.get_current_time()); 269 | return; 270 | } 271 | } 272 | } 273 | 274 | list_workspace(ext: Ext) { 275 | for (const window of ext.tab_list(Meta.TabList.NORMAL, null)) { 276 | this.windows.insert(window); 277 | } 278 | } 279 | 280 | load_desktop_files() { 281 | log.warn('pop-shell: deprecated function called (launcher::load_desktop_files)'); 282 | } 283 | 284 | locate_by_app_info(info: any): null | ShellWindow { 285 | const workspace = this.ext.active_workspace(); 286 | const exec_info: null | string = info.get_string('Exec'); 287 | const exec = exec_info?.split(' ').shift()?.split('/').pop(); 288 | if (exec) { 289 | for (const window of this.ext.tab_list(Meta.TabList.NORMAL, null)) { 290 | if (window.meta.get_workspace().index() !== workspace) continue; 291 | 292 | const pid = window.meta.get_pid(); 293 | if (pid !== -1) { 294 | try { 295 | let f = Gio.File.new_for_path(`/proc/${pid}/cmdline`); 296 | const [, bytes] = f.load_contents(null); 297 | const output: string = imports.byteArray.toString(bytes); 298 | const cmd = output.split(' ').shift()?.split('/').pop(); 299 | if (cmd === exec) return window; 300 | } catch (_) {} 301 | } 302 | } 303 | } 304 | 305 | return null; 306 | } 307 | 308 | open(ext: Ext) { 309 | ext.tiler.exit(ext); 310 | 311 | // Do not allow opening twice 312 | if (this.opened) return; 313 | 314 | // Do not activate if the focused window is fullscreen 315 | if (!ext.settings.fullscreen_launcher() && ext.focus_window()?.meta.is_fullscreen()) return; 316 | 317 | this.opened = true; 318 | 319 | const active_monitor = ext.active_monitor(); 320 | const mon_work_area = ext.monitor_work_area(active_monitor); 321 | const mon_area = ext.monitor_area(active_monitor); 322 | const mon_width = mon_area ? mon_area.width : mon_work_area.width; 323 | 324 | super._open(global.get_current_time(), false); 325 | 326 | if (!this.dialog.visible) { 327 | this.clear(); 328 | this.cancel(); 329 | this.close(); 330 | return; 331 | } 332 | 333 | super.cleanup(); 334 | this.start_services(); 335 | this.search(''); 336 | 337 | this.dialog.dialogLayout.x = mon_width / 2 - this.dialog.dialogLayout.width / 2; 338 | 339 | let height = mon_work_area.height >= 900 ? mon_work_area.height / 2 : mon_work_area.height / 3.5; 340 | this.dialog.dialogLayout.y = height - this.dialog.dialogLayout.height / 2; 341 | } 342 | 343 | start_services() { 344 | if (this.service === null) { 345 | log.debug('starting pop-launcher service'); 346 | const ipc = utils.async_process_ipc(['pop-launcher']); 347 | this.service = ipc ? new service.LauncherService(ipc, (resp) => this.on_response(resp)) : null; 348 | } 349 | } 350 | 351 | stop_services(_ext: Ext) { 352 | if (this.service !== null) { 353 | log.info(`stopping pop-launcher services`); 354 | this.service.exit(); 355 | this.service = null; 356 | } 357 | } 358 | } 359 | -------------------------------------------------------------------------------- /src/launcher_service.ts: -------------------------------------------------------------------------------- 1 | import * as utils from './utils.js'; 2 | import * as log from './log.js'; 3 | 4 | import Gio from 'gi://Gio'; 5 | import GLib from 'gi://GLib'; 6 | const { byteArray } = imports; 7 | 8 | /** Reads JSON responses from the launcher service asynchronously, and sends requests. 9 | * 10 | * # Note 11 | * You must call `LauncherService::exit()` before dropping. 12 | */ 13 | export class LauncherService { 14 | service: utils.AsyncIPC; 15 | 16 | constructor(service: utils.AsyncIPC, callback: (response: JsonIPC.Response) => void) { 17 | this.service = service; 18 | 19 | /** Recursively registers an intent to read the next line asynchronously */ 20 | const generator = (stdout: any, res: any) => { 21 | try { 22 | const [bytes] = stdout.read_line_finish(res); 23 | if (bytes) { 24 | const string = byteArray.toString(bytes); 25 | // log.debug(`received response from launcher service: ${string}`) 26 | callback(JSON.parse(string)); 27 | this.service.stdout.read_line_async(0, this.service.cancellable, generator); 28 | } 29 | } catch (why) { 30 | // Do not print an error if it was merely cancelled. 31 | if ((why as any).matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) { 32 | return; 33 | } 34 | 35 | log.error(`failed to read response from launcher service: ${why}`); 36 | } 37 | }; 38 | 39 | this.service.stdout.read_line_async(0, this.service.cancellable, generator); 40 | } 41 | 42 | activate(id: number) { 43 | this.send({ Activate: id }); 44 | } 45 | 46 | activate_context(id: number, context: number) { 47 | this.send({ ActivateContext: { id, context } }); 48 | } 49 | 50 | complete(id: number) { 51 | this.send({ Complete: id }); 52 | } 53 | 54 | context(id: number) { 55 | this.send({ Context: id }); 56 | } 57 | 58 | exit() { 59 | this.send('Exit'); 60 | this.service.cancellable.cancel(); 61 | const service = this.service; 62 | 63 | GLib.timeout_add(GLib.PRIORITY_DEFAULT, 100, () => { 64 | if (service.stdout.has_pending() || service.stdin.has_pending()) return true; 65 | 66 | const close_stream = (stream: any) => { 67 | try { 68 | stream.close(null); 69 | } catch (why) { 70 | log.error(`failed to close pop-launcher stream: ${why}`); 71 | } 72 | }; 73 | 74 | close_stream(service.stdin); 75 | close_stream(service.stdin); 76 | 77 | // service.child.send_signal(15) 78 | 79 | return false; 80 | }); 81 | } 82 | 83 | query(search: string) { 84 | this.send({ Search: search }); 85 | } 86 | 87 | quit(id: number) { 88 | this.send({ Quit: id }); 89 | } 90 | 91 | select(id: number) { 92 | this.send({ Select: id }); 93 | } 94 | 95 | send(object: Object) { 96 | const message = JSON.stringify(object); 97 | try { 98 | this.service.stdin.write_all(message + '\n', null); 99 | } catch (why) { 100 | log.error(`failed to send request to pop-launcher: ${why}`); 101 | } 102 | } 103 | } 104 | 105 | /** Launcher types transmitted across the wire as JSON. */ 106 | export namespace JsonIPC { 107 | export interface SearchResult { 108 | id: number; 109 | name: string; 110 | description: string; 111 | icon?: IconSource; 112 | category_icon?: IconSource; 113 | window?: [number, number]; 114 | } 115 | 116 | export type IconSource = IconV.Name | IconV.Mime | IconV.Window; 117 | 118 | namespace IconV { 119 | export interface Name { 120 | Name: string; 121 | } 122 | 123 | export interface Mime { 124 | Mime: string; 125 | } 126 | 127 | export interface Window { 128 | Window: [number, number]; 129 | } 130 | } 131 | 132 | export type Response = 133 | | ResponseV.Update 134 | | ResponseV.Fill 135 | | ResponseV.Close 136 | | ResponseV.DesktopEntryR 137 | | ResponseV.Context; 138 | 139 | namespace ResponseV { 140 | export type Close = 'Close'; 141 | 142 | export interface Context { 143 | Context: { 144 | id: number; 145 | options: Array; 146 | }; 147 | } 148 | 149 | export interface ContextOption { 150 | id: number; 151 | name: string; 152 | } 153 | 154 | export interface Update { 155 | Update: Array; 156 | } 157 | 158 | export interface Fill { 159 | Fill: string; 160 | } 161 | 162 | export interface DesktopEntryR { 163 | DesktopEntry: DesktopEntry; 164 | } 165 | } 166 | 167 | export interface DesktopEntry { 168 | path: string; 169 | gpu_preference: GpuPreference; 170 | } 171 | 172 | export type GpuPreference = 'Default' | 'NonDefault'; 173 | } 174 | -------------------------------------------------------------------------------- /src/lib.ts: -------------------------------------------------------------------------------- 1 | import * as log from './log.js'; 2 | import * as rectangle from './rectangle.js'; 3 | 4 | import type { Rectangle } from './rectangle.js'; 5 | 6 | import Meta from 'gi://Meta'; 7 | import St from 'gi://St'; 8 | 9 | export interface SizeHint { 10 | minimum: [number, number]; 11 | increment: [number, number]; 12 | base: [number, number]; 13 | } 14 | 15 | export enum Orientation { 16 | HORIZONTAL = 0, 17 | VERTICAL = 1, 18 | } 19 | 20 | export function nth_rev(array: Array, nth: number): T | null { 21 | return array[array.length - nth - 1]; 22 | } 23 | 24 | export function ok(input: T | null, func: (a: T) => X | null): X | null { 25 | return input ? func(input) : null; 26 | } 27 | 28 | export function ok_or_else(input: A | null, ok_func: (input: A) => B, or_func: () => B): B { 29 | return input ? ok_func(input) : or_func(); 30 | } 31 | 32 | export function or_else(input: T | null, func: () => T | null): T | null { 33 | return input ? input : func(); 34 | } 35 | 36 | export function bench(name: string, callback: () => T): T { 37 | const start = new Date().getMilliseconds(); 38 | const value = callback(); 39 | const end = new Date().getMilliseconds(); 40 | 41 | log.info(`bench ${name}: ${end - start} ms elapsed`); 42 | 43 | return value; 44 | } 45 | 46 | export function current_monitor(): Rectangle { 47 | return rectangle.Rectangle.from_meta( 48 | global.display.get_monitor_geometry(global.display.get_current_monitor()) as Rectangular, 49 | ); 50 | } 51 | 52 | // Fetch rectangle that represents the cursor 53 | export function cursor_rect(): Rectangle { 54 | let [x, y] = global.get_pointer(); 55 | return new rectangle.Rectangle([x, y, 1, 1]); 56 | } 57 | 58 | export function dbg(value: T): T { 59 | log.debug(String(value)); 60 | return value; 61 | } 62 | 63 | /// Missing from the Clutter API is an Actor children iterator 64 | export function* get_children(actor: Clutter.Actor): IterableIterator { 65 | let nth = 0; 66 | let children = actor.get_n_children(); 67 | 68 | while (nth < children) { 69 | const child = actor.get_child_at_index(nth); 70 | if (child) yield child; 71 | nth += 1; 72 | } 73 | } 74 | 75 | export function join(iterator: IterableIterator, next_func: (arg: T) => void, between_func: () => void) { 76 | ok(iterator.next().value, (first) => { 77 | next_func(first); 78 | 79 | for (const item of iterator) { 80 | between_func(); 81 | next_func(item); 82 | } 83 | }); 84 | } 85 | 86 | export function is_keyboard_op(op: number): boolean { 87 | const window_flag_keyboard = Meta.GrabOp.KEYBOARD_MOVING & ~Meta.GrabOp.WINDOW_BASE; 88 | return (op & window_flag_keyboard) != 0; 89 | } 90 | 91 | export function is_resize_op(op: number): boolean { 92 | const window_dir_mask = 93 | (Meta.GrabOp.RESIZING_N | Meta.GrabOp.RESIZING_E | Meta.GrabOp.RESIZING_S | Meta.GrabOp.RESIZING_W) & 94 | ~Meta.GrabOp.WINDOW_BASE; 95 | return ( 96 | (op & window_dir_mask) != 0 || 97 | (op & Meta.GrabOp.KEYBOARD_RESIZING_UNKNOWN) == Meta.GrabOp.KEYBOARD_RESIZING_UNKNOWN 98 | ); 99 | } 100 | 101 | export function is_move_op(op: number): boolean { 102 | return !is_resize_op(op); 103 | } 104 | 105 | export function orientation_as_str(value: number): string { 106 | return value == 0 ? 'Orientation::Horizontal' : 'Orientation::Vertical'; 107 | } 108 | 109 | /// Useful in the event that you want to reuse an actor in the future 110 | export function recursive_remove_children(actor: Clutter.Actor) { 111 | for (const child of get_children(actor)) { 112 | recursive_remove_children(child); 113 | } 114 | 115 | actor.remove_all_children(); 116 | } 117 | 118 | export function round_increment(value: number, increment: number): number { 119 | return Math.round(value / increment) * increment; 120 | } 121 | 122 | export function round_to(n: number, digits: number): number { 123 | let m = Math.pow(10, digits); 124 | n = parseFloat((n * m).toFixed(11)); 125 | return Math.round(n) / m; 126 | } 127 | 128 | export function separator(): any { 129 | return new St.BoxLayout({ styleClass: 'pop-shell-separator', x_expand: true }); 130 | } 131 | -------------------------------------------------------------------------------- /src/log.ts: -------------------------------------------------------------------------------- 1 | // simplified log4j levels 2 | export enum LOG_LEVELS { 3 | OFF, 4 | ERROR, 5 | WARN, 6 | INFO, 7 | DEBUG, 8 | } 9 | 10 | /** 11 | * parse level at runtime so we don't have to restart popshell 12 | */ 13 | export function log_level() { 14 | // log.js is at the level of prefs.js where the popshell Ext instance 15 | // is not yet available or visible, so we have to use the built in 16 | // ExtensionUtils to get the current settings 17 | let settings = globalThis.popShellExtension.getSettings(); 18 | let log_level = settings.get_uint('log-level'); 19 | 20 | return log_level; 21 | } 22 | 23 | export function log(text: string) { 24 | (globalThis as any).log('pop-shell: ' + text); 25 | } 26 | 27 | export function error(text: string) { 28 | if (log_level() > LOG_LEVELS.OFF) log('[ERROR] ' + text); 29 | } 30 | 31 | export function warn(text: string) { 32 | if (log_level() > LOG_LEVELS.ERROR) log('[WARN] ' + text); 33 | } 34 | 35 | export function info(text: string) { 36 | if (log_level() > LOG_LEVELS.WARN) log('[INFO] ' + text); 37 | } 38 | 39 | export function debug(text: string) { 40 | if (log_level() > LOG_LEVELS.INFO) log('[DEBUG] ' + text); 41 | } 42 | -------------------------------------------------------------------------------- /src/mod.d.ts: -------------------------------------------------------------------------------- 1 | declare const global: Global, imports: any, log: any, _: (arg: string) => string; 2 | 3 | interface Global { 4 | get_current_time(): number; 5 | get_pointer(): [number, number]; 6 | get_window_actors(): Array; 7 | log(msg: string): void; 8 | logError(error: any): void; 9 | 10 | display: Meta.Display; 11 | run_at_leisure(func: () => void): void; 12 | session_mode: string; 13 | stage: Clutter.Actor; 14 | window_group: Clutter.Actor; 15 | window_manager: Meta.WindowManager; 16 | workspace_manager: Meta.WorkspaceManager; 17 | } 18 | 19 | interface ImportMeta { 20 | url: string; 21 | } 22 | 23 | interface Rectangular { 24 | x: number; 25 | y: number; 26 | width: number; 27 | height: number; 28 | } 29 | 30 | interface DialogButtonAction { 31 | label: string; 32 | action: () => void; 33 | key?: number; 34 | default?: boolean; 35 | } 36 | 37 | declare type ProcessResult = [boolean, any, any, number]; 38 | declare type SignalID = number; 39 | 40 | declare module 'resource://*'; 41 | 42 | declare module 'gi://Gio' { 43 | let Gio: any; 44 | export default Gio; 45 | } 46 | 47 | declare module 'gi://St' { 48 | let St: any; 49 | export default St; 50 | } 51 | 52 | declare module 'gi://Clutter' { 53 | let Clutter: any; 54 | export default Clutter; 55 | } 56 | 57 | declare module 'gi://Shell' { 58 | let Shell: any; 59 | export default Shell; 60 | } 61 | 62 | declare module 'gi://Meta' { 63 | let Meta: any; 64 | export default Meta; 65 | } 66 | 67 | declare module 'gi://Gtk' { 68 | let Gtk: any; 69 | export default Gtk; 70 | } 71 | 72 | declare module 'gi://Gdk' { 73 | let Gdk: any; 74 | export default Gdk; 75 | } 76 | 77 | declare module 'gi://GObject' { 78 | let GObject: any; 79 | export default GObject; 80 | } 81 | 82 | declare module 'gi://Pango' { 83 | let Pango: any; 84 | export default Pango; 85 | } 86 | 87 | declare module 'gi://GLib' { 88 | class GLib { 89 | PRIORITY_DEFAULT: number; 90 | PRIORITY_LOW: number; 91 | SOURCE_REMOVE: boolean; 92 | SOURCE_CONTINUE: boolean; 93 | 94 | find_program_in_path(prog: string): string | null; 95 | get_current_dir(): string; 96 | get_monotonic_time(): number; 97 | 98 | idle_add(priority: any, callback: () => boolean): number; 99 | 100 | signal_handler_block(object: GObject.Object, signal: SignalID): void; 101 | signal_handler_unblock(object: GObject.Object, signal: SignalID): void; 102 | 103 | source_remove(id: SignalID): void; 104 | spawn_command_line_sync(cmd: string): ProcessResult; 105 | spawn_command_line_async(cmd: string): boolean; 106 | 107 | timeout_add(priority: number, ms: number, callback: () => boolean): number; 108 | 109 | get_user_config_dir(): string; 110 | file_get_contents(filename: string): string; 111 | } 112 | let gLib: GLib; 113 | export default gLib; 114 | } 115 | 116 | declare interface GLib { 117 | PRIORITY_DEFAULT: number; 118 | PRIORITY_LOW: number; 119 | SOURCE_REMOVE: boolean; 120 | SOURCE_CONTINUE: boolean; 121 | 122 | find_program_in_path(prog: string): string | null; 123 | get_current_dir(): string; 124 | get_monotonic_time(): number; 125 | 126 | idle_add(priority: any, callback: () => boolean): number; 127 | 128 | signal_handler_block(object: GObject.Object, signal: SignalID): void; 129 | signal_handler_unblock(object: GObject.Object, signal: SignalID): void; 130 | 131 | source_remove(id: SignalID): void; 132 | spawn_command_line_sync(cmd: string): ProcessResult; 133 | spawn_command_line_async(cmd: string): boolean; 134 | 135 | timeout_add(priority: number, ms: number, callback: () => boolean): number; 136 | } 137 | 138 | declare namespace GObject { 139 | interface Object { 140 | connect(signal: string, callback: (...args: any) => boolean | void): SignalID; 141 | disconnect(id: SignalID): void; 142 | 143 | ref(): this; 144 | } 145 | } 146 | 147 | declare namespace Gtk { 148 | export enum Orientation { 149 | HORIZONTAL, 150 | VERTICAL, 151 | } 152 | 153 | export class Box extends Container { 154 | constructor(orientation: Orientation, spacing: number); 155 | } 156 | 157 | export class Container extends Widget { 158 | constructor(); 159 | add(widget: Widget): void; 160 | set_border_width(border_width: number): void; 161 | } 162 | 163 | export class Widget { 164 | constructor(); 165 | show_all?: () => void; 166 | show(): void; 167 | } 168 | } 169 | 170 | declare namespace Clutter { 171 | enum ActorAlign { 172 | FILL = 0, 173 | START = 1, 174 | CENTER = 3, 175 | END = 3, 176 | } 177 | 178 | enum AnimationMode { 179 | EASE_IN_QUAD = 2, 180 | EASE_OUT_QUAD = 3, 181 | } 182 | 183 | interface Actor extends Rectangular, GObject.Object { 184 | visible: boolean; 185 | x_align: ActorAlign; 186 | y_align: ActorAlign; 187 | opacity: number; 188 | 189 | add(child: Actor): void; 190 | add_child(child: Actor): void; 191 | destroy(): void; 192 | destroy_all_children(): void; 193 | ease(params: Object): void; 194 | hide(): void; 195 | get_child_at_index(nth: number): Clutter.Actor | null; 196 | get_n_children(): number; 197 | get_parent(): Clutter.Actor | null; 198 | get_stage(): Clutter.Actor | null; 199 | get_transition(param: string): any | null; 200 | grab_key_focus(): void; 201 | is_visible(): boolean; 202 | queue_redraw(): void; 203 | remove_all_children(): void; 204 | remove_all_transitions(): void; 205 | remove_child(child: Actor): void; 206 | set_child_above_sibling(child: Actor, sibling: Actor | null): void; 207 | set_child_below_sibling(child: Actor, sibling: Actor | null): void; 208 | set_easing_duration(msecs: number | null): void; 209 | set_opacity(value: number): void; 210 | set_size(width: number, height: number): void; 211 | set_y_align(align: ActorAlign): void; 212 | set_position(x: number, y: number): void; 213 | set_size(width: number, height: number): void; 214 | show(): void; 215 | get_context(): Clutter.Context; 216 | } 217 | 218 | interface ActorBox { 219 | new (x: number, y: number, width: number, height: number): ActorBox; 220 | } 221 | 222 | interface Text extends Actor { 223 | get_text(): Readonly; 224 | set_text(text: string | null): void; 225 | } 226 | 227 | interface Seat extends GObject.Object { 228 | warp_pointer(x: number, y: number): void; 229 | } 230 | 231 | interface Backend extends GObject.Object { 232 | get_default_seat(): Seat; 233 | } 234 | 235 | interface Context extends GObject.Object { 236 | get_backend(): Backend; 237 | } 238 | } 239 | 240 | declare namespace Meta { 241 | enum DisplayDirection { 242 | UP, 243 | DOWN, 244 | LEFT, 245 | RIGHT, 246 | } 247 | 248 | enum MaximizeFlags { 249 | HORIZONTAL = 1, 250 | VERTICAL = 2, 251 | BOTH = 3, 252 | } 253 | 254 | enum MotionDirection { 255 | UP, 256 | DOWN, 257 | LEFT, 258 | RIGHT, 259 | } 260 | 261 | interface Display extends GObject.Object { 262 | get_current_monitor(): number; 263 | get_focus_window(): null | Meta.Window; 264 | get_monitor_index_for_rect(rect: Rectangular): number; 265 | get_monitor_geometry(monitor: number): null | Rectangular; 266 | get_monitor_neighbor_index(monitor: number, direction: DisplayDirection): number; 267 | get_n_monitors(): number; 268 | get_primary_monitor(): number; 269 | get_tab_list(list: number, workspace: Meta.Workspace | null): Array; 270 | get_workspace_manager(): WorkspaceManager; 271 | } 272 | 273 | interface Window extends Clutter.Actor { 274 | appears_focused: Readonly; 275 | minimized: Readonly; 276 | window_type: Readonly; 277 | decorated: Readonly; 278 | 279 | activate(time: number): void; 280 | change_workspace_by_index(workspace: number, append: boolean): void; 281 | delete(timestamp: number): void; 282 | get_buffer_rect(): Rectangular; 283 | get_compositor_private(): Clutter.Actor | null; 284 | get_display(): Meta.Display | null; 285 | get_description(): string; 286 | get_frame_rect(): Rectangular; 287 | get_maximized(): number; 288 | get_monitor(): number; 289 | get_pid(): number; 290 | get_role(): null | string; 291 | get_stable_sequence(): number; 292 | get_title(): null | string; 293 | get_transient_for(): Window | null; 294 | get_user_time(): number; 295 | get_wm_class(): string | null; 296 | get_wm_class_instance(): string | null; 297 | get_work_area_for_monitor(monitor: number): null | Rectangular; 298 | get_workspace(): Workspace; 299 | has_focus(): boolean; 300 | is_above(): boolean; 301 | is_attached_dialog(): boolean; 302 | is_fullscreen(): boolean; 303 | is_on_all_workspaces(): boolean; 304 | is_override_redirect(): boolean; 305 | is_skip_taskbar(): boolean; 306 | make_above(): void; 307 | make_fullscreen(): void; 308 | maximize(flags: MaximizeFlags): void; 309 | move_frame(user_op: boolean, x: number, y: number): void; 310 | move_resize_frame(user_op: boolean, x: number, y: number, w: number, h: number): boolean; 311 | raise(): void; 312 | skip_taskbar: boolean; 313 | unmaximize(flags: any): void; 314 | unminimize(): void; 315 | } 316 | 317 | interface WindowActor extends Clutter.Actor { 318 | get_meta_window(): Meta.Window; 319 | } 320 | 321 | interface WindowManager extends GObject.Object {} 322 | 323 | interface Workspace extends GObject.Object { 324 | n_windows: number; 325 | 326 | activate(time: number): boolean; 327 | activate_with_focus(window: Meta.Window, timestamp: number): void; 328 | get_neighbor(direction: Meta.MotionDirection): null | Workspace; 329 | get_work_area_for_monitor(monitor: number): null | Rectangular; 330 | index(): number; 331 | list_windows(): Array; 332 | } 333 | 334 | interface WorkspaceManager extends GObject.Object { 335 | append_new_workspace(activate: boolean, timestamp: number): Workspace; 336 | get_active_workspace(): Workspace; 337 | get_active_workspace_index(): number; 338 | get_n_workspaces(): number; 339 | get_workspace_by_index(index: number): null | Workspace; 340 | remove_workspace(workspace: Workspace, timestamp: number): void; 341 | reorder_workspace(workspace: Workspace, new_index: number): void; 342 | } 343 | } 344 | 345 | declare namespace Shell { 346 | interface Dialog extends St.Widget { 347 | _dialog: St.Widget; 348 | contentLayout: St.Widget; 349 | } 350 | 351 | interface ModalDialog extends St.Widget { 352 | contentLayout: St.Widget; 353 | dialogLayout: Dialog; 354 | 355 | addButton(action: DialogButtonAction): void; 356 | 357 | close(timestamp: number): void; 358 | open(timestamp: number, on_primary: boolean): void; 359 | 360 | setInitialKeyFocus(actor: Clutter.Actor): void; 361 | } 362 | } 363 | 364 | declare namespace St { 365 | interface Button extends Widget { 366 | set_label(label: string): void; 367 | } 368 | 369 | interface Widget extends Clutter.Actor { 370 | add_style_class_name(name: string): void; 371 | add_style_pseudo_class(name: string): void; 372 | add(child: St.Widget): void; 373 | get_theme_node(): any; 374 | hide(): void; 375 | remove_style_class_name(name: string): void; 376 | remove_style_pseudo_class(name: string): void; 377 | set_style(inlinecss: string): boolean; 378 | set_style_class_name(name: string): void; 379 | set_style_pseudo_class(name: string): void; 380 | show_all(): void; 381 | show(): void; 382 | } 383 | 384 | interface Bin extends St.Widget { 385 | // empty for now 386 | } 387 | 388 | interface Entry extends Widget { 389 | clutter_text: any; 390 | 391 | get_clutter_text(): Clutter.Text; 392 | set_hint_text(hint: string): void; 393 | } 394 | 395 | interface Icon extends Widget { 396 | icon_name: string; 397 | } 398 | 399 | interface Label extends Widget { 400 | text: string; 401 | } 402 | } 403 | -------------------------------------------------------------------------------- /src/movement.ts: -------------------------------------------------------------------------------- 1 | export enum Movement { 2 | NONE = 0, 3 | MOVED = 0b1, 4 | GROW = 0b10, 5 | SHRINK = 0b100, 6 | LEFT = 0b1000, 7 | UP = 0b10000, 8 | RIGHT = 0b100000, 9 | DOWN = 0b1000000, 10 | } 11 | 12 | export function calculate(from: Rectangular, change: Rectangular): Movement { 13 | const xpos = from.x == change.x; 14 | const ypos = from.y == change.y; 15 | 16 | if (xpos && ypos) { 17 | if (from.width == change.width) { 18 | if (from.height == change.width) { 19 | return Movement.NONE; 20 | } else if (from.height < change.height) { 21 | return Movement.GROW | Movement.DOWN; 22 | } else { 23 | return Movement.SHRINK | Movement.UP; 24 | } 25 | } else if (from.width < change.width) { 26 | return Movement.GROW | Movement.RIGHT; 27 | } else { 28 | return Movement.SHRINK | Movement.LEFT; 29 | } 30 | } else if (xpos) { 31 | if (from.height < change.height) { 32 | return Movement.GROW | Movement.UP; 33 | } else { 34 | return Movement.SHRINK | Movement.DOWN; 35 | } 36 | } else if (ypos) { 37 | if (from.width < change.width) { 38 | return Movement.GROW | Movement.LEFT; 39 | } else { 40 | return Movement.SHRINK | Movement.RIGHT; 41 | } 42 | } else { 43 | return Movement.MOVED; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/node.ts: -------------------------------------------------------------------------------- 1 | import * as Ecs from './ecs.js'; 2 | 3 | import type { Forest } from './forest.js'; 4 | import type { Entity } from './ecs.js'; 5 | import type { Ext } from './extension.js'; 6 | import type { Rectangle } from './rectangle.js'; 7 | import type { Stack } from './stack.js'; 8 | import { ShellWindow } from './window.js'; 9 | 10 | /** A node is either a fork a window */ 11 | export enum NodeKind { 12 | FORK = 1, 13 | WINDOW = 2, 14 | STACK = 3, 15 | } 16 | 17 | /** Fetch the string representation of this value */ 18 | function node_variant_as_string(value: NodeKind): string { 19 | return value == NodeKind.FORK ? 'NodeVariant::Fork' : 'NodeVariant::Window'; 20 | } 21 | 22 | /** Identifies this node as a fork */ 23 | export interface NodeFork { 24 | kind: 1; 25 | entity: Entity; 26 | } 27 | 28 | /** Identifies this node as a window */ 29 | export interface NodeWindow { 30 | kind: 2; 31 | entity: Entity; 32 | } 33 | 34 | export interface NodeStack { 35 | kind: 3; 36 | idx: number; 37 | entities: Array; 38 | rect: Rectangle | null; 39 | } 40 | 41 | function stack_detach(node: NodeStack, stack: Stack, idx: number) { 42 | node.entities.splice(idx, 1); 43 | stack.remove_by_pos(idx); 44 | } 45 | 46 | export function stack_find(node: NodeStack, entity: Entity): null | number { 47 | let idx = 0; 48 | while (idx < node.entities.length) { 49 | if (Ecs.entity_eq(entity, node.entities[idx])) { 50 | return idx; 51 | } 52 | idx += 1; 53 | } 54 | 55 | return null; 56 | } 57 | 58 | /** Move the window in a stack to the left, and detach if it it as the end. */ 59 | export function stack_move_left(ext: Ext, forest: Forest, node: NodeStack, entity: Entity): boolean { 60 | const stack = forest.stacks.get(node.idx); 61 | if (!stack) return false; 62 | 63 | let idx = 0; 64 | for (const cmp of node.entities) { 65 | if (Ecs.entity_eq(cmp, entity)) { 66 | if (idx === 0) { 67 | // Remove the window from the stack 68 | stack_detach(node, stack, 0); 69 | return false; 70 | } else { 71 | // Swap tabs in the stack 72 | stack_swap(node, idx - 1, idx); 73 | stack.active_id -= 1; 74 | ext.auto_tiler?.update_stack(ext, node); 75 | return true; 76 | } 77 | } 78 | 79 | idx += 1; 80 | } 81 | 82 | return false; 83 | } 84 | 85 | /** Move the window in a stack to the right, and detach if it is at the end. */ 86 | export function stack_move_right(ext: Ext, forest: Forest, node: NodeStack, entity: Entity): boolean { 87 | const stack = forest.stacks.get(node.idx); 88 | if (!stack) return false; 89 | 90 | let moved = false; 91 | let idx = 0; 92 | const max = node.entities.length - 1; 93 | for (const cmp of node.entities) { 94 | if (Ecs.entity_eq(cmp, entity)) { 95 | if (idx === max) { 96 | stack_detach(node, stack, idx); 97 | moved = false; 98 | } else { 99 | stack_swap(node, idx + 1, idx); 100 | stack.active_id += 1; 101 | ext.auto_tiler?.update_stack(ext, node); 102 | moved = true; 103 | } 104 | break; 105 | } 106 | 107 | idx += 1; 108 | } 109 | 110 | return moved; 111 | } 112 | 113 | export function stack_replace(ext: Ext, node: NodeStack, window: ShellWindow) { 114 | if (!ext.auto_tiler) return; 115 | 116 | const stack = ext.auto_tiler.forest.stacks.get(node.idx); 117 | if (!stack) return; 118 | 119 | stack.replace(window); 120 | } 121 | 122 | /** Removes a window from a stack */ 123 | export function stack_remove(forest: Forest, node: NodeStack, entity: Entity): null | number { 124 | const stack = forest.stacks.get(node.idx); 125 | if (!stack) return null; 126 | const idx = stack.remove_tab(entity); 127 | if (idx !== null) node.entities.splice(idx, 1); 128 | return idx; 129 | } 130 | 131 | function stack_swap(node: NodeStack, from: number, to: number) { 132 | const tmp = node.entities[from]; 133 | node.entities[from] = node.entities[to]; 134 | node.entities[to] = tmp; 135 | } 136 | 137 | export type NodeADT = NodeFork | NodeWindow | NodeStack; 138 | 139 | /** A tiling node may either refer to a window entity, or another fork entity */ 140 | export class Node { 141 | /** The actual data for this node */ 142 | inner: NodeADT; 143 | 144 | constructor(inner: NodeADT) { 145 | this.inner = inner; 146 | } 147 | 148 | /** Create a fork variant of a `Node` */ 149 | static fork(entity: Entity): Node { 150 | return new Node({ kind: NodeKind.FORK, entity }); 151 | } 152 | 153 | /** Create the window variant of a `Node` */ 154 | static window(entity: Entity): Node { 155 | return new Node({ kind: NodeKind.WINDOW, entity }); 156 | } 157 | 158 | static stacked(window: Entity, idx: number): Node { 159 | const node = new Node({ 160 | kind: NodeKind.STACK, 161 | entities: [window], 162 | idx, 163 | rect: null, 164 | }); 165 | 166 | return node; 167 | } 168 | 169 | /** Generates a string representation of the this value. */ 170 | display(fmt: string): string { 171 | fmt += `{\n kind: ${node_variant_as_string(this.inner.kind)},\n `; 172 | 173 | switch (this.inner.kind) { 174 | // Fork + Window 175 | case 1: 176 | case 2: 177 | fmt += `entity: (${this.inner.entity})\n }`; 178 | return fmt; 179 | // Stack 180 | case 3: 181 | fmt += `entities: ${this.inner.entities}\n }`; 182 | return fmt; 183 | } 184 | } 185 | 186 | /** Check if the entity exists as a child of this stack */ 187 | is_in_stack(entity: Entity): boolean { 188 | if (this.inner.kind === 3) { 189 | for (const compare of this.inner.entities) { 190 | if (Ecs.entity_eq(entity, compare)) return true; 191 | } 192 | } 193 | 194 | return false; 195 | } 196 | 197 | /** Asks if this fork is the fork we are looking for */ 198 | is_fork(entity: Entity): boolean { 199 | return this.inner.kind === 1 && Ecs.entity_eq(this.inner.entity, entity); 200 | } 201 | 202 | /** Asks if this window is the window we are looking for */ 203 | is_window(entity: Entity): boolean { 204 | return this.inner.kind === 2 && Ecs.entity_eq(this.inner.entity, entity); 205 | } 206 | 207 | /** Calculates the future arrangement of windows in this node */ 208 | measure( 209 | tiler: Forest, 210 | ext: Ext, 211 | parent: Entity, 212 | area: Rectangle, 213 | record: (win: Entity, parent: Entity, area: Rectangle) => void, 214 | ) { 215 | switch (this.inner.kind) { 216 | // Fork 217 | case 1: 218 | const fork = tiler.forks.get(this.inner.entity); 219 | if (fork) { 220 | record; 221 | fork.measure(tiler, ext, area, record); 222 | } 223 | 224 | break; 225 | // Window 226 | case 2: 227 | record(this.inner.entity, parent, area.clone()); 228 | break; 229 | // Stack 230 | case 3: 231 | const size = ext.dpi * 4; 232 | 233 | this.inner.rect = area.clone(); 234 | this.inner.rect.y += size * 6; 235 | this.inner.rect.height -= size * 6; 236 | 237 | for (const entity of this.inner.entities) { 238 | record(entity, parent, this.inner.rect); 239 | } 240 | 241 | if (ext.auto_tiler) { 242 | ext.auto_tiler.forest.stack_updates.push([this.inner, parent]); 243 | } 244 | } 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /src/once_cell.ts: -------------------------------------------------------------------------------- 1 | export class OnceCell { 2 | value: T | undefined; 3 | 4 | constructor() {} 5 | 6 | get_or_init(callback: () => T): T { 7 | if (this.value === undefined) { 8 | this.value = callback(); 9 | } 10 | 11 | return this.value; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/panel_settings.ts: -------------------------------------------------------------------------------- 1 | import * as Utils from './utils.js'; 2 | 3 | import type { Ext } from './extension.js'; 4 | 5 | import Clutter from 'gi://Clutter'; 6 | import Gio from 'gi://Gio'; 7 | import St from 'gi://St'; 8 | 9 | import { 10 | PopupBaseMenuItem, 11 | PopupMenuItem, 12 | PopupSwitchMenuItem, 13 | PopupSeparatorMenuItem, 14 | } from 'resource:///org/gnome/shell/ui/popupMenu.js'; 15 | import { Button } from 'resource:///org/gnome/shell/ui/panelMenu.js'; 16 | import GLib from 'gi://GLib'; 17 | import { spawn } from 'resource:///org/gnome/shell/misc/util.js'; 18 | import { get_current_path } from './paths.js'; 19 | // import * as Settings from './settings.js'; 20 | 21 | export class Indicator { 22 | button: any; 23 | appearances: any; 24 | 25 | toggle_tiled: any; 26 | toggle_titles: null | any; 27 | toggle_active: any; 28 | border_radius: any; 29 | 30 | entry_gaps: any; 31 | 32 | constructor(ext: Ext) { 33 | this.button = new Button(0.0, _('Pop Shell Settings')); 34 | 35 | const path = get_current_path(); 36 | ext.button = this.button; 37 | ext.button_gio_icon_auto_on = Gio.icon_new_for_string(`${path}/icons/pop-shell-auto-on-symbolic.svg`); 38 | ext.button_gio_icon_auto_off = Gio.icon_new_for_string(`${path}/icons/pop-shell-auto-off-symbolic.svg`); 39 | 40 | let button_icon_auto_on = new St.Icon({ 41 | gicon: ext.button_gio_icon_auto_on, 42 | style_class: 'system-status-icon', 43 | }); 44 | let button_icon_auto_off = new St.Icon({ 45 | gicon: ext.button_gio_icon_auto_off, 46 | style_class: 'system-status-icon', 47 | }); 48 | 49 | if (ext.settings.tile_by_default()) { 50 | this.button.icon = button_icon_auto_on; 51 | } else { 52 | this.button.icon = button_icon_auto_off; 53 | } 54 | 55 | this.button.add_child(this.button.icon); 56 | 57 | let bm = this.button.menu; 58 | 59 | this.toggle_tiled = tiled(ext); 60 | 61 | this.toggle_active = toggle(_('Show Active Hint'), ext.settings.active_hint(), (toggle) => { 62 | ext.settings.set_active_hint(toggle.state); 63 | }); 64 | 65 | this.entry_gaps = number_entry(_('Gaps'), ext.settings.gap_inner(), (value) => { 66 | ext.settings.set_gap_inner(value); 67 | ext.settings.set_gap_outer(value); 68 | }); 69 | 70 | this.border_radius = number_entry( 71 | _('Active Border Radius'), 72 | { 73 | value: ext.settings.active_hint_border_radius(), 74 | min: 0, 75 | max: 30, 76 | }, 77 | (value) => { 78 | ext.settings.set_active_hint_border_radius(value); 79 | }, 80 | ); 81 | 82 | bm.addMenuItem(this.toggle_tiled); 83 | bm.addMenuItem(floating_window_exceptions(ext, bm)); 84 | 85 | bm.addMenuItem(menu_separator('')); 86 | bm.addMenuItem(shortcuts(bm)); 87 | bm.addMenuItem(settings_button(bm)); 88 | bm.addMenuItem(menu_separator('')); 89 | 90 | if (!Utils.is_wayland()) { 91 | this.toggle_titles = show_title(ext); 92 | bm.addMenuItem(this.toggle_titles); 93 | } 94 | 95 | bm.addMenuItem(this.toggle_active); 96 | bm.addMenuItem(this.border_radius); 97 | 98 | // CSS Selector 99 | bm.addMenuItem(color_selector(ext, bm)); 100 | 101 | bm.addMenuItem(this.entry_gaps); 102 | } 103 | 104 | destroy() { 105 | this.button.destroy(); 106 | } 107 | } 108 | 109 | function menu_separator(text: any): any { 110 | return new PopupSeparatorMenuItem(text); 111 | } 112 | 113 | function settings_button(menu: any): any { 114 | let item = new PopupMenuItem(_('View All')); 115 | item.connect('activate', () => { 116 | let path: string | null = GLib.find_program_in_path('pop-shell-shortcuts'); 117 | if (path) { 118 | spawn([path]); 119 | } else { 120 | spawn(['xdg-open', 'https://support.system76.com/articles/pop-keyboard-shortcuts/']); 121 | } 122 | 123 | menu.close(); 124 | }); 125 | 126 | item.label.get_clutter_text().set_margin_left(12); 127 | 128 | return item; 129 | } 130 | 131 | function floating_window_exceptions(ext: Ext, menu: any): any { 132 | let label = new St.Label({ text: 'Floating Window Exceptions' }); 133 | label.set_x_expand(true); 134 | 135 | let icon = new St.Icon({ icon_name: 'go-next-symbolic', icon_size: 16 }); 136 | 137 | let widget = new St.BoxLayout({ vertical: false }); 138 | widget.add_child(label); 139 | widget.add_child(icon); 140 | widget.set_x_expand(true); 141 | 142 | let base = new PopupBaseMenuItem(); 143 | base.add_child(widget); 144 | base.connect('activate', () => { 145 | ext.exception_dialog(); 146 | 147 | GLib.timeout_add(GLib.PRIORITY_LOW, 300, () => { 148 | menu.close(); 149 | return false; 150 | }); 151 | }); 152 | 153 | return base; 154 | } 155 | 156 | function shortcuts(menu: any): any { 157 | let layout_manager = new Clutter.GridLayout({ orientation: Clutter.Orientation.HORIZONTAL }); 158 | let widget = new St.Widget({ layout_manager, x_expand: true }); 159 | 160 | let item = new PopupBaseMenuItem(); 161 | item.add_child(widget); 162 | item.connect('activate', () => { 163 | let path: string | null = GLib.find_program_in_path('pop-shell-shortcuts'); 164 | if (path) { 165 | spawn([path]); 166 | } else { 167 | spawn(['xdg-open', 'https://support.system76.com/articles/pop-keyboard-shortcuts/']); 168 | } 169 | 170 | menu.close(); 171 | }); 172 | 173 | function create_label(text: string): any { 174 | return new St.Label({ text }); 175 | } 176 | 177 | function create_shortcut_label(text: string): any { 178 | let label = create_label(text); 179 | label.set_x_align(Clutter.ActorAlign.END); 180 | return label; 181 | } 182 | 183 | layout_manager.set_row_spacing(12); 184 | layout_manager.set_column_spacing(30); 185 | layout_manager.attach(create_label(_('Shortcuts')), 0, 0, 2, 1); 186 | 187 | let launcher_shortcut = _('Super + /'); 188 | // const cosmic_settings = Settings.settings_new_id( 189 | // 'org.gnome.shell.extensions.pop-cosmic' 190 | // ); 191 | // if (cosmic_settings) { 192 | // if (cosmic_settings.get_enum('overlay-key-action') === 2) { 193 | // launcher_shortcut = _('Super'); 194 | // } 195 | // } 196 | 197 | [ 198 | [_('Launcher'), launcher_shortcut], 199 | [_('Navigate Windows'), _('Super + Arrow Keys')], 200 | [_('Toggle Tiling'), _('Super + Y')], 201 | ].forEach((section, idx) => { 202 | let key = create_label(section[0]); 203 | key.get_clutter_text().set_margin_left(12); 204 | 205 | let val = create_shortcut_label(section[1]); 206 | 207 | layout_manager.attach(key, 0, idx + 1, 1, 1); 208 | layout_manager.attach(val, 1, idx + 1, 1, 1); 209 | }); 210 | 211 | return item; 212 | } 213 | 214 | function clamp(input: number, min = 0, max = 128): number { 215 | return Math.min(Math.max(min, input), max); 216 | } 217 | 218 | function number_entry( 219 | label: string, 220 | valueOrOptions: number | { value: number; min: number; max: number }, 221 | callback: (a: number) => void, 222 | ): any { 223 | let value = valueOrOptions, 224 | min: number, 225 | max: number; 226 | if (typeof valueOrOptions !== 'number') ({ value, min, max } = valueOrOptions); 227 | 228 | const entry = new St.Entry({ 229 | text: String(value), 230 | input_purpose: Clutter.InputContentPurpose.NUMBER, 231 | x_align: Clutter.ActorAlign.CENTER, 232 | x_expand: false, 233 | }); 234 | 235 | entry.set_style('width: 5em'); 236 | entry.connect('button-release-event', () => { 237 | return true; 238 | }); 239 | 240 | const text = entry.clutter_text; 241 | text.set_max_length(2); 242 | 243 | entry.connect('key-release-event', (_: any, event: any) => { 244 | const symbol = event.get_key_symbol(); 245 | 246 | const number: number | null = 247 | symbol == 65293 // enter key 248 | ? parse_number(text.text) 249 | : symbol == 65361 // left key 250 | ? clamp(parse_number(text.text) - 1, min, max) 251 | : symbol == 65363 // right key 252 | ? clamp(parse_number(text.text) + 1, min, max) 253 | : null; 254 | 255 | if (number !== null) { 256 | text.set_text(String(number)); 257 | } 258 | }); 259 | 260 | const create_icon = (icon_name: string) => { 261 | return new St.Icon({ icon_name, icon_size: 16 }); 262 | }; 263 | 264 | entry.set_primary_icon(create_icon('value-decrease')); 265 | entry.connect('primary-icon-clicked', () => { 266 | text.set_text(String(clamp(parseInt(text.get_text()) - 1, min, max))); 267 | }); 268 | 269 | entry.set_secondary_icon(create_icon('value-increase')); 270 | entry.connect('secondary-icon-clicked', () => { 271 | text.set_text(String(clamp(parseInt(text.get_text()) + 1, min, max))); 272 | }); 273 | 274 | text.connect('text-changed', () => { 275 | const input: string = text.get_text(); 276 | let parsed = parseInt(input); 277 | 278 | if (isNaN(parsed)) { 279 | text.set_text(input.substr(0, input.length - 1)); 280 | parsed = 0; 281 | } 282 | 283 | callback(parsed); 284 | }); 285 | 286 | const item = new PopupMenuItem(label); 287 | item.label.get_clutter_text().set_x_expand(true); 288 | item.label.set_y_align(Clutter.ActorAlign.CENTER); 289 | item.add_child(entry); 290 | 291 | return item; 292 | } 293 | 294 | function parse_number(text: string): number { 295 | let number = parseInt(text, 10); 296 | if (isNaN(number)) { 297 | number = 0; 298 | } 299 | 300 | return number; 301 | } 302 | 303 | function show_title(ext: Ext): any { 304 | const t = toggle(_('Show Window Titles'), ext.settings.show_title(), (toggle: any) => { 305 | ext.settings.set_show_title(toggle.state); 306 | }); 307 | 308 | return t; 309 | } 310 | 311 | function toggle(desc: string, active: boolean, connect: (toggle: any, state: boolean) => void): any { 312 | let toggle = new PopupSwitchMenuItem(desc, active); 313 | 314 | toggle.label.set_y_align(Clutter.ActorAlign.CENTER); 315 | 316 | toggle.connect('toggled', (_: any, state: boolean) => { 317 | connect(toggle, state); 318 | return true; 319 | }); 320 | 321 | return toggle; 322 | } 323 | 324 | function tiled(ext: Ext): any { 325 | let t = toggle(_('Tile Windows'), null != ext.auto_tiler, (_, shouldTile) => { 326 | if (shouldTile) { 327 | ext.auto_tile_on(); 328 | } else { 329 | ext.auto_tile_off(); 330 | } 331 | }); 332 | return t; 333 | } 334 | 335 | function color_selector(ext: Ext, menu: any) { 336 | let color_selector_item = new PopupMenuItem('Active Hint Color'); 337 | let color_button = new St.Button(); 338 | let settings = ext.settings; 339 | let selected_color = settings.hint_color_rgba(); 340 | 341 | // TODO, find a way to expand the button text, :) 342 | color_button.label = ' '; // blank for now 343 | color_button.set_style(`background-color: ${selected_color}; border: 2px solid lightgray; border-radius: 2px`); 344 | 345 | settings.ext.connect('changed', (_, key) => { 346 | if (key === 'hint-color-rgba') { 347 | let color_value = settings.hint_color_rgba(); 348 | color_button.set_style(`background-color: ${color_value}; border: 2px solid lightgray; border-radius: 2px`); 349 | } 350 | }); 351 | 352 | color_button.set_x_align(Clutter.ActorAlign.END); 353 | color_button.set_x_expand(false); 354 | 355 | color_selector_item.label.get_clutter_text().set_x_expand(true); 356 | color_selector_item.label.set_y_align(Clutter.ActorAlign.CENTER); 357 | 358 | color_selector_item.add_child(color_button); 359 | color_button.connect('button-press-event', () => { 360 | let path = get_current_path() + '/color_dialog/main.js'; 361 | let resp = GLib.spawn_command_line_async(`gjs --module ${path}`); 362 | if (!resp) { 363 | return null; 364 | } 365 | 366 | // clean up and focus on the color dialog 367 | GLib.timeout_add(GLib.PRIORITY_LOW, 300, () => { 368 | menu.close(); 369 | return false; 370 | }); 371 | }); 372 | 373 | return color_selector_item; 374 | } 375 | -------------------------------------------------------------------------------- /src/paths.ts: -------------------------------------------------------------------------------- 1 | export function get_current_path(): string { 2 | return import.meta.url.split('://')[1].split('/').slice(0, -1).join('/'); 3 | } 4 | -------------------------------------------------------------------------------- /src/prefs.ts: -------------------------------------------------------------------------------- 1 | import Gtk from 'gi://Gtk'; 2 | 3 | import Gio from 'gi://Gio'; 4 | const Settings = Gio.Settings; 5 | import { ExtensionPreferences } from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js'; 6 | 7 | import * as settings from './settings.js'; 8 | import * as log from './log.js'; 9 | import * as focus from './focus.js'; 10 | 11 | interface AppWidgets { 12 | fullscreen_launcher: any; 13 | stacking_with_mouse: any; 14 | inner_gap: any; 15 | mouse_cursor_follows_active_window: any; 16 | outer_gap: any; 17 | show_skip_taskbar: any; 18 | smart_gaps: any; 19 | snap_to_grid: any; 20 | window_titles: any; 21 | mouse_cursor_focus_position: any; 22 | log_level: any; 23 | max_window_width: any; 24 | } 25 | 26 | export default class PopShellPreferences extends ExtensionPreferences { 27 | getPreferencesWidget() { 28 | globalThis.popShellExtension = this; 29 | let dialog = settings_dialog_new(); 30 | if (dialog.show_all) { 31 | dialog.show_all(); 32 | } else { 33 | dialog.show(); 34 | } 35 | log.debug(JSON.stringify(dialog)); 36 | return dialog; 37 | } 38 | } 39 | 40 | function settings_dialog_new(): Gtk.Container { 41 | let [app, grid] = settings_dialog_view(); 42 | 43 | let ext = new settings.ExtensionSettings(); 44 | 45 | app.window_titles.set_active(ext.show_title()); 46 | app.window_titles.connect('state-set', (_widget: any, state: boolean) => { 47 | ext.set_show_title(state); 48 | Settings.sync(); 49 | }); 50 | 51 | app.snap_to_grid.set_active(ext.snap_to_grid()); 52 | app.snap_to_grid.connect('state-set', (_widget: any, state: boolean) => { 53 | ext.set_snap_to_grid(state); 54 | Settings.sync(); 55 | }); 56 | 57 | app.smart_gaps.set_active(ext.smart_gaps()); 58 | app.smart_gaps.connect('state-set', (_widget: any, state: boolean) => { 59 | ext.set_smart_gaps(state); 60 | Settings.sync(); 61 | }); 62 | 63 | app.outer_gap.set_text(String(ext.gap_outer())); 64 | app.outer_gap.connect('activate', (widget: any) => { 65 | let parsed = parseInt((widget.get_text() as string).trim()); 66 | if (!isNaN(parsed)) { 67 | ext.set_gap_outer(parsed); 68 | Settings.sync(); 69 | } 70 | }); 71 | 72 | app.inner_gap.set_text(String(ext.gap_inner())); 73 | app.inner_gap.connect('activate', (widget: any) => { 74 | let parsed = parseInt((widget.get_text() as string).trim()); 75 | if (!isNaN(parsed)) { 76 | ext.set_gap_inner(parsed); 77 | Settings.sync(); 78 | } 79 | }); 80 | 81 | app.log_level.set_active(ext.log_level()); 82 | app.log_level.connect('changed', () => { 83 | let active_id = app.log_level.get_active_id(); 84 | ext.set_log_level(active_id); 85 | }); 86 | 87 | app.show_skip_taskbar.set_active(ext.show_skiptaskbar()); 88 | app.show_skip_taskbar.connect('state-set', (_widget: any, state: boolean) => { 89 | ext.set_show_skiptaskbar(state); 90 | Settings.sync(); 91 | }); 92 | 93 | app.mouse_cursor_follows_active_window.set_active(ext.mouse_cursor_follows_active_window()); 94 | app.mouse_cursor_follows_active_window.connect('state-set', (_widget: any, state: boolean) => { 95 | ext.set_mouse_cursor_follows_active_window(state); 96 | Settings.sync(); 97 | }); 98 | 99 | app.mouse_cursor_focus_position.set_active(ext.mouse_cursor_focus_location()); 100 | app.mouse_cursor_focus_position.connect('changed', () => { 101 | let active_id = app.mouse_cursor_focus_position.get_active_id(); 102 | ext.set_mouse_cursor_focus_location(active_id); 103 | }); 104 | 105 | app.fullscreen_launcher.set_active(ext.fullscreen_launcher()); 106 | app.fullscreen_launcher.connect('state-set', (_widget: any, state: boolean) => { 107 | ext.set_fullscreen_launcher(state); 108 | Settings.sync(); 109 | }); 110 | 111 | app.stacking_with_mouse.set_active(ext.stacking_with_mouse()); 112 | app.stacking_with_mouse.connect('state-set', (_widget: any, state: boolean) => { 113 | ext.set_stacking_with_mouse(state); 114 | Settings.sync(); 115 | }); 116 | 117 | app.max_window_width.set_text(String(ext.max_window_width())); 118 | app.max_window_width.connect('activate', (widget: any) => { 119 | let parsed = parseInt((widget.get_text() as string).trim()); 120 | if (!isNaN(parsed)) { 121 | ext.set_max_window_width(parsed); 122 | Settings.sync(); 123 | } 124 | }); 125 | 126 | return grid; 127 | } 128 | 129 | function settings_dialog_view(): [AppWidgets, Gtk.Container] { 130 | const grid = new Gtk.Grid({ 131 | column_spacing: 12, 132 | row_spacing: 12, 133 | margin_start: 10, 134 | margin_end: 10, 135 | margin_bottom: 10, 136 | margin_top: 10, 137 | }); 138 | 139 | const win_label = new Gtk.Label({ 140 | label: 'Show Window Titles', 141 | xalign: 0.0, 142 | hexpand: true, 143 | }); 144 | 145 | const snap_label = new Gtk.Label({ 146 | label: 'Snap to Grid (Floating Mode)', 147 | xalign: 0.0, 148 | }); 149 | 150 | const smart_label = new Gtk.Label({ 151 | label: 'Smart Gaps', 152 | xalign: 0.0, 153 | }); 154 | 155 | const show_skip_taskbar_label = new Gtk.Label({ 156 | label: 'Show Minimize to Tray Windows', 157 | xalign: 0.0, 158 | }); 159 | 160 | const mouse_cursor_follows_active_window_label = new Gtk.Label({ 161 | label: 'Mouse Cursor Follows Active Window', 162 | xalign: 0.0, 163 | }); 164 | 165 | const fullscreen_launcher_label = new Gtk.Label({ 166 | label: 'Allow launcher over fullscreen window', 167 | xalign: 0.0, 168 | }); 169 | 170 | const stacking_with_mouse = new Gtk.Label({ 171 | label: 'Allow stacking with mouse', 172 | xalign: 0.0, 173 | }); 174 | 175 | const max_window_width_label = new Gtk.Label({ 176 | label: 'Max window width (in pixels); 0 to disable', 177 | xalign: 0.0, 178 | }); 179 | 180 | const [inner_gap, outer_gap] = gaps_section(grid, 9); 181 | 182 | const settings = { 183 | inner_gap, 184 | outer_gap, 185 | fullscreen_launcher: new Gtk.Switch({ halign: Gtk.Align.END }), 186 | stacking_with_mouse: new Gtk.Switch({ halign: Gtk.Align.END }), 187 | smart_gaps: new Gtk.Switch({ halign: Gtk.Align.END }), 188 | snap_to_grid: new Gtk.Switch({ halign: Gtk.Align.END }), 189 | window_titles: new Gtk.Switch({ halign: Gtk.Align.END }), 190 | show_skip_taskbar: new Gtk.Switch({ halign: Gtk.Align.END }), 191 | mouse_cursor_follows_active_window: new Gtk.Switch({ halign: Gtk.Align.END }), 192 | mouse_cursor_focus_position: build_combo(grid, 7, focus.FocusPosition, 'Mouse Cursor Focus Position'), 193 | log_level: build_combo(grid, 8, log.LOG_LEVELS, 'Log Level'), 194 | max_window_width: number_entry(), 195 | }; 196 | 197 | grid.attach(win_label, 0, 0, 1, 1); 198 | grid.attach(settings.window_titles, 1, 0, 1, 1); 199 | 200 | grid.attach(snap_label, 0, 1, 1, 1); 201 | grid.attach(settings.snap_to_grid, 1, 1, 1, 1); 202 | 203 | grid.attach(smart_label, 0, 2, 1, 1); 204 | grid.attach(settings.smart_gaps, 1, 2, 1, 1); 205 | 206 | grid.attach(fullscreen_launcher_label, 0, 3, 1, 1); 207 | grid.attach(settings.fullscreen_launcher, 1, 3, 1, 1); 208 | 209 | grid.attach(stacking_with_mouse, 0, 4, 1, 1); 210 | grid.attach(settings.stacking_with_mouse, 1, 4, 1, 1); 211 | 212 | grid.attach(show_skip_taskbar_label, 0, 5, 1, 1); 213 | grid.attach(settings.show_skip_taskbar, 1, 5, 1, 1); 214 | 215 | grid.attach(mouse_cursor_follows_active_window_label, 0, 6, 1, 1); 216 | grid.attach(settings.mouse_cursor_follows_active_window, 1, 6, 1, 1); 217 | 218 | grid.attach(max_window_width_label, 0, 12, 1, 1); 219 | grid.attach(settings.max_window_width, 1, 12, 1, 1); 220 | 221 | return [settings, grid]; 222 | } 223 | 224 | function gaps_section(grid: any, top: number): [any, any] { 225 | let outer_label = new Gtk.Label({ 226 | label: 'Outer', 227 | xalign: 0.0, 228 | margin_start: 24, 229 | }); 230 | 231 | let outer_entry = number_entry(); 232 | 233 | let inner_label = new Gtk.Label({ 234 | label: 'Inner', 235 | xalign: 0.0, 236 | margin_start: 24, 237 | }); 238 | 239 | let inner_entry = number_entry(); 240 | 241 | let section_label = new Gtk.Label({ 242 | label: 'Gaps (in pixels)', 243 | xalign: 0.0, 244 | }); 245 | 246 | grid.attach(section_label, 0, top, 1, 1); 247 | grid.attach(outer_label, 0, top + 1, 1, 1); 248 | grid.attach(outer_entry, 1, top + 1, 1, 1); 249 | grid.attach(inner_label, 0, top + 2, 1, 1); 250 | grid.attach(inner_entry, 1, top + 2, 1, 1); 251 | 252 | return [inner_entry, outer_entry]; 253 | } 254 | 255 | function number_entry(): Gtk.Widget { 256 | return new Gtk.Entry({ input_purpose: Gtk.InputPurpose.NUMBER }); 257 | } 258 | 259 | function build_combo(grid: any, top_index: number, iter_enum: any, label: string) { 260 | let label_ = new Gtk.Label({ 261 | label: label, 262 | halign: Gtk.Align.START, 263 | }); 264 | 265 | grid.attach(label_, 0, top_index, 1, 1); 266 | 267 | let combo = new Gtk.ComboBoxText(); 268 | 269 | for (const [index, key] of Object.keys(iter_enum).entries()) { 270 | if (typeof iter_enum[key] == 'string') { 271 | combo.append(`${index}`, iter_enum[key]); 272 | } 273 | } 274 | 275 | grid.attach(combo, 1, top_index, 1, 1); 276 | return combo; 277 | } 278 | -------------------------------------------------------------------------------- /src/rectangle.ts: -------------------------------------------------------------------------------- 1 | export class Rectangle { 2 | array: [number, number, number, number]; 3 | 4 | constructor(array: [number, number, number, number]) { 5 | this.array = array; 6 | } 7 | 8 | static from_meta(meta: Rectangular): Rectangle { 9 | return new Rectangle([meta.x, meta.y, meta.width, meta.height]); 10 | } 11 | 12 | get x() { 13 | return this.array[0]; 14 | } 15 | 16 | set x(x: number) { 17 | this.array[0] = x; 18 | } 19 | 20 | get y() { 21 | return this.array[1]; 22 | } 23 | 24 | set y(y: number) { 25 | this.array[1] = y; 26 | } 27 | 28 | get width(): number { 29 | return this.array[2]; 30 | } 31 | 32 | set width(width: number) { 33 | this.array[2] = width; 34 | } 35 | 36 | get height() { 37 | return this.array[3]; 38 | } 39 | 40 | set height(height: number) { 41 | this.array[3] = height; 42 | } 43 | 44 | apply(other: Rectangle): this { 45 | this.x += other.x; 46 | this.y += other.y; 47 | this.width += other.width; 48 | this.height += other.height; 49 | return this; 50 | } 51 | 52 | clamp(other: Rectangular) { 53 | this.x = Math.max(other.x, this.x); 54 | this.y = Math.max(other.y, this.y); 55 | 56 | let tend = this.x + this.width, 57 | oend = other.x + other.width; 58 | if (tend > oend) { 59 | this.width = oend - this.x; 60 | } 61 | 62 | tend = this.y + this.height; 63 | oend = other.y + other.height; 64 | if (tend > oend) { 65 | this.height = oend - this.y; 66 | } 67 | } 68 | 69 | clone(): Rectangle { 70 | return new Rectangle([this.array[0], this.array[1], this.array[2], this.array[3]]); 71 | } 72 | 73 | contains(other: Rectangular): boolean { 74 | return ( 75 | this.x <= other.x && 76 | this.y <= other.y && 77 | this.x + this.width >= other.x + other.width && 78 | this.y + this.height >= other.y + other.height 79 | ); 80 | } 81 | 82 | diff(other: Rectangular): Rectangle { 83 | return new Rectangle([ 84 | other.x - this.x, 85 | other.y - this.y, 86 | other.width - this.width, 87 | other.height - this.height, 88 | ]); 89 | } 90 | 91 | eq(other: Rectangular): boolean { 92 | return this.x == other.x && this.y == other.y && this.width == other.width && this.height == other.height; 93 | } 94 | 95 | fmt(): string { 96 | return `Rect(${[this.x, this.y, this.width, this.height]})`; 97 | } 98 | 99 | intersects(other: Rectangular): boolean { 100 | return ( 101 | this.x < other.x + other.width && 102 | this.x + this.width > other.x && 103 | this.y < other.y + other.height && 104 | this.y + this.height > other.y 105 | ); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/result.ts: -------------------------------------------------------------------------------- 1 | export const OK = 1; 2 | export const ERR = 2; 3 | 4 | export type Result = Ok | Err; 5 | 6 | export interface Ok { 7 | kind: 1; 8 | value: T; 9 | } 10 | 11 | export interface Err { 12 | kind: 2; 13 | value: T; 14 | } 15 | 16 | export function Ok(value: T): Result { 17 | return { kind: 1, value: value }; 18 | } 19 | 20 | export function Err(value: E): Result { 21 | return { kind: 2, value: value }; 22 | } 23 | -------------------------------------------------------------------------------- /src/scheduler.ts: -------------------------------------------------------------------------------- 1 | import * as log from './log.js'; 2 | 3 | import Gio from 'gi://Gio'; 4 | 5 | const SchedulerInterface = 6 | '\ 7 | \ 8 | \ 9 | \ 10 | \ 11 | \ 12 | '; 13 | 14 | const SchedulerProxy = Gio.DBusProxy.makeProxyWrapper(SchedulerInterface); 15 | 16 | const SchedProxy = new SchedulerProxy(Gio.DBus.system, 'com.system76.Scheduler', '/com/system76/Scheduler'); 17 | 18 | let foreground: number = 0; 19 | let failed: boolean = false; 20 | 21 | export function setForeground(win: Meta.Window) { 22 | if (failed) return; 23 | 24 | const pid = win.get_pid(); 25 | if (pid) { 26 | if (foreground === pid) return; 27 | foreground = pid; 28 | 29 | try { 30 | SchedProxy.SetForegroundProcessRemote(pid, (_result: any, error: any, _fds: any) => { 31 | if (error !== null) errorHandler(error); 32 | }); 33 | } catch (error) { 34 | errorHandler(error); 35 | } 36 | } 37 | } 38 | 39 | function errorHandler(error: any) { 40 | log.warn(`system76-scheduler may not be installed and running: ${error}`); 41 | failed = true; 42 | } 43 | -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | // const Me = imports.misc.extensionUtils.getCurrentExtension(); 2 | import Gio from 'gi://Gio'; 3 | import Gdk from 'gi://Gdk'; 4 | import { get_current_path } from './paths.js'; 5 | 6 | const DARK = ['dark', 'adapta', 'plata', 'dracula']; 7 | 8 | interface Settings extends GObject.Object { 9 | get_boolean(key: string): boolean; 10 | set_boolean(key: string, value: boolean): void; 11 | 12 | get_uint(key: string): number; 13 | set_uint(key: string, value: number): void; 14 | 15 | get_string(key: string): string; 16 | set_string(key: string, value: string): void; 17 | 18 | bind(key: string, object: GObject.Object, property: string, flags: any): void; 19 | } 20 | 21 | function settings_new_id(schema_id: string): Settings | null { 22 | try { 23 | return new Gio.Settings({ schema_id }); 24 | } catch (why) { 25 | if (schema_id !== 'org.gnome.shell.extensions.user-theme') { 26 | // global.log(`failed to get settings for ${schema_id}: ${why}`); 27 | } 28 | 29 | return null; 30 | } 31 | } 32 | 33 | function settings_new_schema(schema: string): Settings { 34 | const GioSSS = Gio.SettingsSchemaSource; 35 | const schemaDir = Gio.File.new_for_path(get_current_path()).get_child('schemas'); 36 | 37 | let schemaSource = schemaDir.query_exists(null) 38 | ? GioSSS.new_from_directory(schemaDir.get_path(), GioSSS.get_default(), false) 39 | : GioSSS.get_default(); 40 | 41 | const schemaObj = schemaSource.lookup(schema, true); 42 | 43 | if (!schemaObj) { 44 | throw new Error( 45 | 'Schema ' + schema + ' could not be found for extension pop-shell' + '. Please check your installation.', 46 | ); 47 | } 48 | 49 | return new Gio.Settings({ settings_schema: schemaObj }); 50 | } 51 | 52 | const ACTIVE_HINT = 'active-hint'; 53 | const ACTIVE_HINT_BORDER_RADIUS = 'active-hint-border-radius'; 54 | const STACKING_WITH_MOUSE = 'stacking-with-mouse'; 55 | const COLUMN_SIZE = 'column-size'; 56 | const EDGE_TILING = 'edge-tiling'; 57 | const FULLSCREEN_LAUNCHER = 'fullscreen-launcher'; 58 | const GAP_INNER = 'gap-inner'; 59 | const GAP_OUTER = 'gap-outer'; 60 | const ROW_SIZE = 'row-size'; 61 | const SHOW_TITLE = 'show-title'; 62 | const SMART_GAPS = 'smart-gaps'; 63 | const SNAP_TO_GRID = 'snap-to-grid'; 64 | const TILE_BY_DEFAULT = 'tile-by-default'; 65 | const HINT_COLOR_RGBA = 'hint-color-rgba'; 66 | const DEFAULT_RGBA_COLOR = 'rgba(251, 184, 108, 1)'; //pop-orange 67 | const LOG_LEVEL = 'log-level'; 68 | const SHOW_SKIPTASKBAR = 'show-skip-taskbar'; 69 | const MOUSE_CURSOR_FOLLOWS_ACTIVE_WINDOW = 'mouse-cursor-follows-active-window'; 70 | const MOUSE_CURSOR_FOCUS_LOCATION = 'mouse-cursor-focus-location'; 71 | const MAX_WINDOW_WIDTH = 'max-window-width'; 72 | 73 | export class ExtensionSettings { 74 | ext: Settings = settings_new_schema('org.gnome.shell.extensions.pop-shell'); 75 | int: Settings | null = settings_new_id('org.gnome.desktop.interface'); 76 | mutter: Settings | null = settings_new_id('org.gnome.mutter'); 77 | shell: Settings | null = settings_new_id('org.gnome.shell.extensions.user-theme'); 78 | 79 | // Getters 80 | 81 | active_hint(): boolean { 82 | return this.ext.get_boolean(ACTIVE_HINT); 83 | } 84 | 85 | active_hint_border_radius(): number { 86 | return this.ext.get_uint(ACTIVE_HINT_BORDER_RADIUS); 87 | } 88 | 89 | stacking_with_mouse(): boolean { 90 | return this.ext.get_boolean(STACKING_WITH_MOUSE); 91 | } 92 | 93 | column_size(): number { 94 | return this.ext.get_uint(COLUMN_SIZE); 95 | } 96 | 97 | dynamic_workspaces(): boolean { 98 | return this.mutter ? this.mutter.get_boolean('dynamic-workspaces') : false; 99 | } 100 | 101 | fullscreen_launcher(): boolean { 102 | return this.ext.get_boolean(FULLSCREEN_LAUNCHER); 103 | } 104 | 105 | gap_inner(): number { 106 | return this.ext.get_uint(GAP_INNER); 107 | } 108 | 109 | gap_outer(): number { 110 | return this.ext.get_uint(GAP_OUTER); 111 | } 112 | 113 | hint_color_rgba() { 114 | let rgba = this.ext.get_string(HINT_COLOR_RGBA); 115 | let valid_color = new Gdk.RGBA().parse(rgba); 116 | 117 | if (!valid_color) { 118 | return DEFAULT_RGBA_COLOR; 119 | } 120 | 121 | return rgba; 122 | } 123 | 124 | theme(): string { 125 | return this.shell ? this.shell.get_string('name') : this.int ? this.int.get_string('gtk-theme') : 'Adwaita'; 126 | } 127 | 128 | is_dark(): boolean { 129 | const theme = this.theme().toLowerCase(); 130 | return DARK.some((dark) => theme.includes(dark)); 131 | } 132 | 133 | is_high_contrast(): boolean { 134 | return this.theme().toLowerCase() === 'highcontrast'; 135 | } 136 | 137 | row_size(): number { 138 | return this.ext.get_uint(ROW_SIZE); 139 | } 140 | 141 | show_title(): boolean { 142 | return this.ext.get_boolean(SHOW_TITLE); 143 | } 144 | 145 | smart_gaps(): boolean { 146 | return this.ext.get_boolean(SMART_GAPS); 147 | } 148 | 149 | snap_to_grid(): boolean { 150 | return this.ext.get_boolean(SNAP_TO_GRID); 151 | } 152 | 153 | tile_by_default(): boolean { 154 | return this.ext.get_boolean(TILE_BY_DEFAULT); 155 | } 156 | 157 | workspaces_only_on_primary(): boolean { 158 | return this.mutter ? this.mutter.get_boolean('workspaces-only-on-primary') : false; 159 | } 160 | 161 | log_level(): number { 162 | return this.ext.get_uint(LOG_LEVEL); 163 | } 164 | 165 | show_skiptaskbar(): boolean { 166 | return this.ext.get_boolean(SHOW_SKIPTASKBAR); 167 | } 168 | 169 | mouse_cursor_follows_active_window(): boolean { 170 | return this.ext.get_boolean(MOUSE_CURSOR_FOLLOWS_ACTIVE_WINDOW); 171 | } 172 | 173 | mouse_cursor_focus_location(): number { 174 | return this.ext.get_uint(MOUSE_CURSOR_FOCUS_LOCATION); 175 | } 176 | 177 | max_window_width(): number { 178 | return this.ext.get_uint(MAX_WINDOW_WIDTH); 179 | } 180 | 181 | // Setters 182 | 183 | set_active_hint(set: boolean) { 184 | this.ext.set_boolean(ACTIVE_HINT, set); 185 | } 186 | 187 | set_active_hint_border_radius(set: number) { 188 | this.ext.set_uint(ACTIVE_HINT_BORDER_RADIUS, set); 189 | } 190 | 191 | set_stacking_with_mouse(set: boolean) { 192 | this.ext.set_boolean(STACKING_WITH_MOUSE, set); 193 | } 194 | 195 | set_column_size(size: number) { 196 | this.ext.set_uint(COLUMN_SIZE, size); 197 | } 198 | 199 | set_edge_tiling(enable: boolean) { 200 | this.mutter?.set_boolean(EDGE_TILING, enable); 201 | } 202 | 203 | set_fullscreen_launcher(enable: boolean) { 204 | this.ext.set_boolean(FULLSCREEN_LAUNCHER, enable); 205 | } 206 | 207 | set_gap_inner(gap: number) { 208 | this.ext.set_uint(GAP_INNER, gap); 209 | } 210 | 211 | set_gap_outer(gap: number) { 212 | this.ext.set_uint(GAP_OUTER, gap); 213 | } 214 | 215 | set_hint_color_rgba(rgba: string) { 216 | let valid_color = new Gdk.RGBA().parse(rgba); 217 | 218 | if (valid_color) { 219 | this.ext.set_string(HINT_COLOR_RGBA, rgba); 220 | } else { 221 | this.ext.set_string(HINT_COLOR_RGBA, DEFAULT_RGBA_COLOR); 222 | } 223 | } 224 | 225 | set_row_size(size: number) { 226 | this.ext.set_uint(ROW_SIZE, size); 227 | } 228 | 229 | set_show_title(set: boolean) { 230 | this.ext.set_boolean(SHOW_TITLE, set); 231 | } 232 | 233 | set_smart_gaps(set: boolean) { 234 | this.ext.set_boolean(SMART_GAPS, set); 235 | } 236 | 237 | set_snap_to_grid(set: boolean) { 238 | this.ext.set_boolean(SNAP_TO_GRID, set); 239 | } 240 | 241 | set_tile_by_default(set: boolean) { 242 | this.ext.set_boolean(TILE_BY_DEFAULT, set); 243 | } 244 | 245 | set_log_level(set: number) { 246 | this.ext.set_uint(LOG_LEVEL, set); 247 | } 248 | 249 | set_show_skiptaskbar(set: boolean) { 250 | this.ext.set_boolean(SHOW_SKIPTASKBAR, set); 251 | } 252 | 253 | set_mouse_cursor_follows_active_window(set: boolean) { 254 | this.ext.set_boolean(MOUSE_CURSOR_FOLLOWS_ACTIVE_WINDOW, set); 255 | } 256 | 257 | set_mouse_cursor_focus_location(set: number) { 258 | this.ext.set_uint(MOUSE_CURSOR_FOCUS_LOCATION, set); 259 | } 260 | 261 | set_max_window_width(set: number) { 262 | this.ext.set_uint(MAX_WINDOW_WIDTH, set); 263 | } 264 | } 265 | -------------------------------------------------------------------------------- /src/shell.ts: -------------------------------------------------------------------------------- 1 | export function monitor_neighbor_index(which: number, direction: Meta.DisplayDirection): number | null { 2 | const neighbor: number = global.display.get_monitor_neighbor_index(which, direction); 3 | return neighbor < 0 ? null : neighbor; 4 | } 5 | -------------------------------------------------------------------------------- /src/shortcut_overlay.ts: -------------------------------------------------------------------------------- 1 | import GObject from 'gi://GObject'; 2 | import St from 'gi://St'; 3 | 4 | import * as Lib from './lib.js'; 5 | 6 | const { separator } = Lib; 7 | 8 | export class Shortcut { 9 | description: string; 10 | bindings: Array>; 11 | 12 | constructor(description: string) { 13 | this.description = description; 14 | this.bindings = new Array(); 15 | } 16 | 17 | add(binding: Array) { 18 | this.bindings.push(binding); 19 | return this; 20 | } 21 | } 22 | 23 | export class Section { 24 | header: string; 25 | shortcuts: Array; 26 | 27 | constructor(header: string, shortcuts: Array) { 28 | this.header = header; 29 | this.shortcuts = shortcuts; 30 | } 31 | } 32 | 33 | export class Column { 34 | sections: Array
; 35 | 36 | constructor(sections: Array
) { 37 | this.sections = sections; 38 | } 39 | } 40 | 41 | export var ShortcutOverlay = GObject.registerClass( 42 | class ShortcutOverlay extends St.BoxLayout { 43 | title: string; 44 | columns: Array; 45 | 46 | constructor() { 47 | super(); 48 | this.title = ''; 49 | this.columns = new Array(); 50 | } 51 | 52 | _init(title: string, columns: Array) { 53 | super.init({ 54 | styleClass: 'pop-shell-shortcuts', 55 | destroyOnClose: false, 56 | shellReactive: true, 57 | shouldFadeIn: true, 58 | shouldFadeOut: true, 59 | }); 60 | 61 | let columns_layout = new St.BoxLayout({ 62 | styleClass: 'pop-shell-shortcuts-columns', 63 | horizontal: true, 64 | }); 65 | 66 | for (const column of columns) { 67 | let column_layout = new St.BoxLayout({ 68 | styleClass: 'pop-shell-shortcuts-column', 69 | }); 70 | 71 | for (const section of column.sections) { 72 | column_layout.add(this.gen_section(section)); 73 | } 74 | 75 | columns_layout.add(column_layout); 76 | } 77 | 78 | this.add( 79 | new St.Label({ 80 | styleClass: 'pop-shell-shortcuts-title', 81 | text: title, 82 | }), 83 | ); 84 | 85 | this.add(columns_layout); 86 | 87 | // TODO: Add hyperlink for shortcuts in settings 88 | } 89 | 90 | gen_combination(combination: Array) { 91 | let layout = new St.BoxLayout({ 92 | styleClass: 'pop-shell-binding', 93 | horizontal: true, 94 | }); 95 | 96 | for (const key of combination) { 97 | layout.add(St.Label({ text: key })); 98 | } 99 | 100 | return layout; 101 | } 102 | 103 | gen_section(section: Section) { 104 | let layout = new St.BoxLayout({ 105 | styleclass: 'pop-shell-section', 106 | }); 107 | 108 | layout.add( 109 | new St.Label({ 110 | styleClass: 'pop-shell-section-header', 111 | text: section.header, 112 | }), 113 | ); 114 | 115 | for (const subsection of section.shortcuts) { 116 | layout.add(separator()); 117 | layout.add(this.gen_shortcut(subsection)); 118 | } 119 | 120 | return layout; 121 | } 122 | 123 | gen_shortcut(shortcut: Shortcut) { 124 | let layout = new St.BoxLayout({ 125 | styleClass: 'pop-shell-shortcut', 126 | horizontal: true, 127 | }); 128 | 129 | layout.add( 130 | new St.Label({ 131 | text: shortcut.description, 132 | }), 133 | ); 134 | 135 | // for (const binding of shortcut.bindings) { 136 | // join( 137 | // binding.values(), 138 | // (comb) => layout.add(this.gen_combination(comb)), 139 | // () => layout.add(new St.Label({ text: 'or' })) 140 | // ); 141 | // } 142 | 143 | return layout; 144 | } 145 | }, 146 | ); 147 | -------------------------------------------------------------------------------- /src/tags.ts: -------------------------------------------------------------------------------- 1 | export var Tiled = 0; 2 | export var Floating = 1; 3 | export var Blocked = 2; 4 | export var ForceTile = 3; 5 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | /** The ID of a monitor in the display server. */ 2 | type MonitorID = number; 3 | 4 | /** The ID of a workspace in GNOME Shell */ 5 | type WorkspaceID = number; 6 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import * as result from './result.js'; 2 | import * as error from './error.js'; 3 | import * as log from './log.js'; 4 | 5 | import Gio from 'gi://Gio'; 6 | import GLib from 'gi://GLib'; 7 | import GObject from 'gi://GObject'; 8 | import Meta from 'gi://Meta'; 9 | const { Ok, Err } = result; 10 | const { Error } = error; 11 | 12 | export function is_wayland(): boolean { 13 | return Meta.is_wayland_compositor(); 14 | } 15 | 16 | export function block_signal(object: GObject.Object, signal: SignalID) { 17 | GObject.signal_handler_block(object, signal); 18 | } 19 | 20 | export function unblock_signal(object: GObject.Object, signal: SignalID) { 21 | GObject.signal_handler_unblock(object, signal); 22 | } 23 | 24 | export function read_to_string(path: string): result.Result { 25 | const file = Gio.File.new_for_path(path); 26 | try { 27 | const [ok, contents] = file.load_contents(null); 28 | if (ok) { 29 | return Ok(imports.byteArray.toString(contents)); 30 | } else { 31 | return Err(new Error(`failed to load contents of ${path}`)); 32 | } 33 | } catch (e) { 34 | return Err(new Error(String(e)).context(`failed to load contents of ${path}`)); 35 | } 36 | } 37 | 38 | export function source_remove(id: SignalID): boolean { 39 | return GLib.source_remove(id) as any; 40 | } 41 | 42 | export function exists(path: string): boolean { 43 | return Gio.File.new_for_path(path).query_exists(null); 44 | } 45 | 46 | /** 47 | * Parse the current background color's darkness 48 | * https://stackoverflow.com/a/41491220 - the advanced solution 49 | * @param color - the RGBA or hex string value 50 | */ 51 | export function is_dark(color: string): boolean { 52 | // 'rgba(251, 184, 108, 1)' - pop orange! 53 | let color_val = ''; 54 | let r = 255; 55 | let g = 255; 56 | let b = 255; 57 | 58 | // handle rgba(255,255,255,1.0) format 59 | if (color.indexOf('rgb') >= 0) { 60 | // starts with parsed value from Gdk.RGBA 61 | color = color.replace('rgba', 'rgb').replace('rgb(', '').replace(')', ''); // make it 255, 255, 255, 1 62 | // log.debug(`util color: ${color}`); 63 | let colors = color.split(','); 64 | r = parseInt(colors[0].trim()); 65 | g = parseInt(colors[1].trim()); 66 | b = parseInt(colors[2].trim()); 67 | } else if (color.charAt(0) === '#') { 68 | color_val = color.substring(1, 7); 69 | r = parseInt(color_val.substring(0, 2), 16); // hexToR 70 | g = parseInt(color_val.substring(2, 4), 16); // hexToG 71 | b = parseInt(color_val.substring(4, 6), 16); // hexToB 72 | } 73 | 74 | let uicolors = [r / 255, g / 255, b / 255]; 75 | let c = uicolors.map((col) => { 76 | if (col <= 0.03928) { 77 | return col / 12.92; 78 | } 79 | return Math.pow((col + 0.055) / 1.055, 2.4); 80 | }); 81 | let L = 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; 82 | return L <= 0.179; 83 | } 84 | 85 | /** Utility function for running a process in the background and fetching its standard output as a string. */ 86 | export function async_process(argv: Array, input = null, cancellable: null | any = null): Promise { 87 | let flags = Gio.SubprocessFlags.STDOUT_PIPE; 88 | 89 | if (input !== null) flags |= Gio.SubprocessFlags.STDIN_PIPE; 90 | 91 | let proc = new Gio.Subprocess({ argv, flags }); 92 | proc.init(cancellable); 93 | 94 | proc.wait_async(null, (source: any, res: any) => { 95 | source.wait_finish(res); 96 | if (cancellable !== null) { 97 | cancellable.cancel(); 98 | } 99 | }); 100 | 101 | return new Promise((resolve, reject) => { 102 | proc.communicate_utf8_async(input, cancellable, (proc: any, res: any) => { 103 | try { 104 | let bytes = proc.communicate_utf8_finish(res)[1]; 105 | resolve(bytes.toString()); 106 | } catch (e) { 107 | reject(e); 108 | } 109 | }); 110 | }); 111 | } 112 | 113 | export type AsyncIPC = { 114 | child: any; 115 | stdout: any; 116 | stdin: any; 117 | cancellable: any; 118 | }; 119 | 120 | export function async_process_ipc(argv: Array): AsyncIPC | null { 121 | const { SubprocessLauncher, SubprocessFlags } = Gio; 122 | 123 | const launcher = new SubprocessLauncher({ 124 | flags: SubprocessFlags.STDIN_PIPE | SubprocessFlags.STDOUT_PIPE, 125 | }); 126 | 127 | let child: any; 128 | 129 | let cancellable = new Gio.Cancellable(); 130 | 131 | try { 132 | child = launcher.spawnv(argv); 133 | } catch (why) { 134 | log.error(`failed to spawn ${argv}: ${why}`); 135 | return null; 136 | } 137 | 138 | let stdin = new Gio.DataOutputStream({ 139 | base_stream: child.get_stdin_pipe(), 140 | close_base_stream: true, 141 | }); 142 | 143 | let stdout = new Gio.DataInputStream({ 144 | base_stream: child.get_stdout_pipe(), 145 | close_base_stream: true, 146 | }); 147 | 148 | child.wait_async(null, (source: any, res: any) => { 149 | source.wait_finish(res); 150 | cancellable.cancel(); 151 | }); 152 | 153 | return { child, stdin, stdout, cancellable }; 154 | } 155 | 156 | export function map_eq(map1: Map, map2: Map) { 157 | if (map1.size !== map2.size) { 158 | return false; 159 | } 160 | 161 | let cmp; 162 | 163 | for (let [key, val] of map1) { 164 | cmp = map2.get(key); 165 | if (cmp !== val || (cmp === undefined && !map2.has(key))) { 166 | return false; 167 | } 168 | } 169 | 170 | return true; 171 | } 172 | 173 | export function os_release(): null | string { 174 | const [ok, bytes] = GLib.file_get_contents('/etc/os-release'); 175 | if (!ok) return null; 176 | 177 | const contents: string = imports.byteArray.toString(bytes); 178 | for (const line of contents.split('\n')) { 179 | if (line.startsWith('VERSION_ID')) { 180 | return line.split('"')[1]; 181 | } 182 | } 183 | 184 | return null; 185 | } 186 | -------------------------------------------------------------------------------- /src/xprop.ts: -------------------------------------------------------------------------------- 1 | import * as lib from './lib.js'; 2 | 3 | import GLib from 'gi://GLib'; 4 | import { spawn } from 'resource:///org/gnome/shell/misc/util.js'; 5 | 6 | export var MOTIF_HINTS: string = '_MOTIF_WM_HINTS'; 7 | export var HIDE_FLAGS: string[] = ['0x2', '0x0', '0x0', '0x0', '0x0']; 8 | export var SHOW_FLAGS: string[] = ['0x2', '0x0', '0x1', '0x0', '0x0']; 9 | 10 | //export var FRAME_EXTENTS: string = "_GTK_FRAME_EXTENTS" 11 | 12 | export function get_window_role(xid: string): string | null { 13 | let out = xprop_cmd(xid, 'WM_WINDOW_ROLE'); 14 | 15 | if (!out) return null; 16 | 17 | return parse_string(out); 18 | } 19 | 20 | export function get_frame_extents(xid: string): string | null { 21 | let out = xprop_cmd(xid, "_GTK_FRAME_EXTENTS"); 22 | 23 | if (!out) return null; 24 | 25 | return parse_string(out) 26 | } 27 | 28 | export function get_hint(xid: string, hint: string): Array | null { 29 | let out = xprop_cmd(xid, hint); 30 | 31 | if (!out) return null; 32 | 33 | const array = parse_cardinal(out); 34 | 35 | return array ? array.map((value) => (value.startsWith('0x') ? value : '0x' + value)) : null; 36 | } 37 | 38 | function size_params(line: string): [number, number] | null { 39 | let fields = line.split(' '); 40 | let x = lib.dbg(lib.nth_rev(fields, 2)); 41 | let y = lib.dbg(lib.nth_rev(fields, 0)); 42 | 43 | if (!x || !y) return null; 44 | 45 | let xn = parseInt(x, 10); 46 | let yn = parseInt(y, 10); 47 | 48 | return isNaN(xn) || isNaN(yn) ? null : [xn, yn]; 49 | } 50 | 51 | export function get_size_hints(xid: string): lib.SizeHint | null { 52 | let out = xprop_cmd(xid, 'WM_NORMAL_HINTS'); 53 | if (out) { 54 | let lines = out.split('\n')[Symbol.iterator](); 55 | lines.next(); 56 | 57 | let minimum: string | undefined = lines.next().value; 58 | let increment: string | undefined = lines.next().value; 59 | let base: string | undefined = lines.next().value; 60 | 61 | if (!minimum || !increment || !base) return null; 62 | 63 | let min_values = size_params(minimum); 64 | let inc_values = size_params(increment); 65 | let base_values = size_params(base); 66 | 67 | if (!min_values || !inc_values || !base_values) return null; 68 | 69 | return { 70 | minimum: min_values, 71 | increment: inc_values, 72 | base: base_values, 73 | }; 74 | } 75 | 76 | return null; 77 | } 78 | 79 | export function get_xid(meta: Meta.Window): string | null { 80 | const desc = meta.get_description(); 81 | const match = desc && desc.match(/0x[0-9a-f]+/); 82 | return match && match[0]; 83 | } 84 | 85 | export function may_decorate(xid: string): boolean { 86 | const hints = motif_hints(xid); 87 | return hints ? hints[2] == '0x0' || hints[2] == '0x1' : true; 88 | } 89 | 90 | export function motif_hints(xid: string): Array | null { 91 | return get_hint(xid, MOTIF_HINTS); 92 | } 93 | 94 | export function set_hint(xid: string, hint: string, value: string[]) { 95 | spawn(['xprop', '-id', xid, '-f', hint, '32c', '-set', hint, value.join(', ')]); 96 | } 97 | 98 | function consume_key(string: string): number | null { 99 | const pos = string.indexOf('='); 100 | return -1 == pos ? null : pos; 101 | } 102 | 103 | function parse_cardinal(string: string): Array | null { 104 | const pos = consume_key(string); 105 | return pos 106 | ? string 107 | .slice(pos + 1) 108 | .trim() 109 | .split(', ') 110 | : null; 111 | } 112 | 113 | function parse_string(string: string): string | null { 114 | const pos = consume_key(string); 115 | return pos 116 | ? string 117 | .slice(pos + 1) 118 | .trim() 119 | .slice(1, -1) 120 | : null; 121 | } 122 | 123 | function xprop_cmd(xid: string, args: string): string | null { 124 | let xprops = GLib.spawn_command_line_sync(`xprop -id ${xid} ${args}`); 125 | if (!xprops[0]) return null; 126 | 127 | return imports.byteArray.toString(xprops[1]); 128 | } 129 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.2.0", 3 | "compileOnSave": true, 4 | "compilerOptions": { 5 | "target": "es2020", 6 | "strict": true, 7 | "outDir": "./target", 8 | "forceConsistentCasingInFileNames": true, 9 | "downlevelIteration": true, 10 | "lib": [ 11 | "es2020" 12 | ], 13 | "pretty": true, 14 | "removeComments": true, 15 | "incremental": true, 16 | "noUnusedLocals": true, 17 | "noUnusedParameters": true, 18 | "moduleResolution": "NodeNext", 19 | "module": "NodeNext" 20 | }, 21 | "include": [ 22 | "src/*.ts" 23 | ] 24 | } --------------------------------------------------------------------------------