├── .editorconfig ├── .github └── workflows │ └── release.yml ├── .gitignore ├── .prettierrc ├── .vscode └── tasks.json ├── CHANGELOG.md ├── README.md ├── Screenshot.png ├── icon.png ├── metadata.json ├── package-lock.json ├── package.json ├── scripts └── build.sh ├── src ├── extension.ts ├── preferences │ ├── AppearancePage.ts │ ├── BehaviorPage.ts │ ├── DropDownChoice.ts │ ├── ShortcutsPage.ts │ ├── common.ts │ └── custom-styles.ts ├── prefs.ts ├── schemas │ └── org.gnome.shell.extensions.space-bar.gschema.xml ├── services │ ├── KeyBindings.ts │ ├── ScrollHandler.ts │ ├── Settings.ts │ ├── Styles.ts │ ├── TopBarAdjustments.ts │ ├── WorkspaceNames.ts │ └── Workspaces.ts ├── stylesheet.css ├── types │ ├── dummy │ │ ├── gi │ │ │ ├── Adw.d.ts │ │ │ ├── Clutter.d.ts │ │ │ ├── GLib.d.ts │ │ │ ├── GObject.d.ts │ │ │ ├── Gdk.ts │ │ │ ├── Gio.d.ts │ │ │ ├── Gtk.d.ts │ │ │ ├── Meta.d.ts │ │ │ ├── Shell.ts │ │ │ └── St.d.ts │ │ ├── shell │ │ │ ├── extensions │ │ │ │ ├── extension.js.d.ts │ │ │ │ └── prefs.js.d.ts │ │ │ └── ui │ │ │ │ ├── altTab.js.d.ts │ │ │ │ ├── dnd.js.d.ts │ │ │ │ ├── main.js.d.ts │ │ │ │ ├── panelMenu.js.d.ts │ │ │ │ ├── popupMenu.js.d.ts │ │ │ │ ├── windowManager.js.d.ts │ │ │ │ └── windowPreview.js.d.ts │ │ └── types.d.ts │ └── generated │ │ ├── gi │ │ ├── Adw.d.ts │ │ ├── Clutter.d.ts │ │ ├── GLib.d.ts │ │ ├── GObject.d.ts │ │ ├── Gdk.d.ts │ │ ├── Gio.d.ts │ │ ├── Gtk.d.ts │ │ ├── Meta.d.ts │ │ ├── Shell.d.ts │ │ └── St.d.ts │ │ └── types.d.ts ├── ui │ ├── WorkspacesBar.ts │ └── WorkspacesBarMenu.ts └── utils │ ├── DebouncingNotifier.ts │ ├── Subject.ts │ ├── Timeout.ts │ ├── Widget.ts │ └── hook.ts ├── tsconfig.build.json └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 4 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | max_line_length = 100 -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | tags: 5 | - 'v*' 6 | jobs: 7 | Release: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v3 11 | - uses: actions/setup-node@v3 12 | with: 13 | node-version: '18' 14 | - run: sudo apt install -y libgnome-autoar-0-0 15 | - run: apt download gnome-shell 16 | - run: sudo dpkg --force-all -i gnome-shell*.deb 17 | - run: npm install 18 | - run: npm run build 19 | - uses: actions/create-release@v1 20 | id: create_release 21 | with: 22 | draft: false 23 | prerelease: false 24 | release_name: ${{ github.ref }} 25 | tag_name: ${{ github.ref }} 26 | body_path: CHANGELOG.md 27 | env: 28 | GITHUB_TOKEN: ${{ github.token }} 29 | - uses: actions/upload-release-asset@v1 30 | env: 31 | GITHUB_TOKEN: ${{ github.token }} 32 | with: 33 | upload_url: ${{ steps.create_release.outputs.upload_url }} 34 | asset_path: ./space-bar@luchrioh.shell-extension.zip 35 | asset_name: space-bar@luchrioh.shell-extension.zip 36 | asset_content_type: application/zip -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | @types 2 | target 3 | space-bar@luchrioh.shell-extension.zip 4 | node_modules -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Build and install", 6 | "command": "./scripts/build.sh -i", 7 | "type": "shell", 8 | "args": [], 9 | "problemMatcher": ["$tsc"], 10 | "presentation": { 11 | "reveal": "always" 12 | }, 13 | "group": { 14 | "kind": "build", 15 | "isDefault": true 16 | } 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## v33 2 | 3 | - Chore: Add support for GNOME 48 4 | 5 | ## v32 6 | 7 | - Feature: Back-and-forth 8 | 9 | ## v31 10 | 11 | - Feature: Add class `.workspace-box-` 12 | 13 | ## v30 14 | 15 | - Fix: Error when loading styles 16 | 17 | ## v29 18 | 19 | - Feature: Custom styles 20 | - Fix: Workspace names are moved around on static workspaces 21 | 22 | ## v28 23 | 24 | - Chore: Add support for GNOME 47 25 | 26 | ## v27 27 | 28 | - Feature: Option customize workspace labels 29 | 30 | ## v26 31 | 32 | - Experimental feature: Re-evaluate smart workspace names 33 | 34 | ## v25 35 | 36 | - Chore: Migrate to GNOME 46 37 | - Feature: Support touch input 38 | - Fix: Delay when opening the overview 39 | 40 | ## v24 41 | 42 | - Feature: Option to keep system workspace indicator 43 | 44 | ## v23 45 | 46 | - Chore: Migrate to GNOME 45 47 | 48 | ## v22 49 | 50 | - Feature: Always show workspace numbers 51 | - Feature: Indicator style "Current workspace" 52 | - Fix: Include empty workspaces for rearrange shortcuts 53 | 54 | ## v21 55 | 56 | - Feature: Shortcuts to rearrange workspaces 57 | 58 | ## v20 59 | 60 | - Feature: Scroll-wheel direction options 61 | - Feature: Adjustable font size 62 | - Removed feature: Remove workspace with middle click 63 | 64 | ## v19 65 | 66 | - Feature: Scroll-wheel wrap around 67 | - Fix: Scroll wheel over workspaces bar doesn't work 68 | 69 | ## v18 70 | 71 | - Feature: New option "Toggle overview" replaces "Open overview when clicking on an empty workspace" 72 | - Feature: Handle inserting workspaces by dragging windows between workspace thumbnails in overview 73 | - Feature: Handle reordering workspaces with other extensions 74 | - Fix: Smart workspaces names take `null` as window class and restore wrong names when windows 75 | aren't ready yet 76 | 77 | ## v17 78 | 79 | - Fix: Crash when the extension opens the overview in some situations 80 | - Fix: Error when activating smart workspace names with fewer workspaces than names 81 | 82 | ## v16 83 | 84 | - Fix: Assigning shortcuts on Xorg with Super modifier or with active Num Lock does not work 85 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Space Bar 2 | 3 | GNOME Shell extension that replaces the top-panel workspace indicator with an i3-like workspaces bar. 4 | 5 | On GNOME Extensions: https://extensions.gnome.org/extension/5090/space-bar/ 6 | 7 | Originally a fork of the extension [Workspaces 8 | Bar](https://extensions.gnome.org/extension/3851/workspaces-bar/) by 9 | [fthx](https://extensions.gnome.org/accounts/profile/fthx), this extension grew into a more 10 | comprehensive set of features to support a workspace-based workflow. 11 | 12 | ## Features 13 | 14 | - First class support for static and dynamic workspaces as well as multi-monitor setups 15 | - Add, remove, and rename workspaces 16 | - Rearrange workspaces via drag and drop 17 | - Automatically assign workspace names based on started applications 18 | - Keyboard shortcuts extend and refine system shortcuts 19 | - Scroll through workspaces by mouse wheel over the panel 20 | - Customize the appearance 21 | 22 | ## Build 23 | 24 | The source code of this extension is written in TypeScript. The following command will build the 25 | extension and package it to a zip file. 26 | 27 | ```sh 28 | ./scripts/build.sh 29 | ``` 30 | 31 | ## Install 32 | 33 | The following command will build the extension and install it locally. 34 | 35 | ```sh 36 | ./scripts/build.sh -i 37 | ``` 38 | 39 | ## Generate types 40 | 41 | For development with TypeScript, you can get type support in IDEs like VSCode by building and 42 | installing type information for used libraries. Generating types is optional and not required for 43 | building the extension. (For that, we use a different configuration that stubs type information with 44 | dummy types.) 45 | 46 | To generate types, run 47 | 48 | ```sh 49 | npm install 50 | npm run build:types 51 | ``` 52 | 53 | Choose "All" and "Yes" for everything. 54 | 55 | ## Debug 56 | 57 | Run a GNOME shell instance in a window: 58 | 59 | ```sh 60 | dbus-run-session -- gnome-shell --nested --wayland 61 | ``` 62 | 63 | View logs: 64 | 65 | ```sh 66 | journalctl -f -o cat /usr/bin/gnome-shell 67 | ``` 68 | 69 | View logs of settings: 70 | 71 | ```sh 72 | journalctl -f -o cat /usr/bin/gjs 73 | ``` 74 | 75 | Configuration: 76 | 77 | ```sh 78 | GSETTINGS_SCHEMA_DIR=::$HOME/.local/share/gnome-shell/extensions/space-bar@luchrioh/schemas dconf-editor /org/gnome/shell/extensions/space-bar/ &>/dev/null 79 | ``` 80 | -------------------------------------------------------------------------------- /Screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/christopher-l/space-bar/b228b3ac2435d66a08cbd74e5ee17728f0470190/Screenshot.png -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/christopher-l/space-bar/b228b3ac2435d66a08cbd74e5ee17728f0470190/icon.png -------------------------------------------------------------------------------- /metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "uuid": "space-bar@luchrioh", 3 | "name": "Space Bar", 4 | "description": "Replaces the top-panel workspace indicator with an i3-like workspaces bar.\n\nOriginally a fork of the extension Workspaces Bar by fthx, this extension grew into a more comprehensive set of features to support a workspace-based workflow.\n\nFeatures:\n- First class support for static and dynamic workspaces as well as multi-monitor setups\n- Add, remove, and rename workspaces\n- Rearrange workspaces via drag and drop\n- Automatically assign workspace names based on started applications\n- Keyboard shortcuts extend and refine system shortcuts\n- Scroll through workspaces by mouse wheel over the panel\n- Customize the appearance", 5 | "shell-version": ["46", "47", "48"], 6 | "url": "https://github.com/christopher-l/space-bar", 7 | "version": 33, 8 | "settings-schema": "org.gnome.shell.extensions.space-bar" 9 | } 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "build": "./scripts/build.sh", 4 | "install": "./scripts/build.sh -i", 5 | "build:types": "ts-for-gir generate Gio-2.0 GObject-2.0 St-14 Shell-14 Meta-14 Adw-1 Clutter-14 -g \"/usr/share/gir-1.0\" -g \"/usr/share/gnome-shell\" -g \"/usr/lib/mutter-14/\"" 6 | }, 7 | "devDependencies": { 8 | "@ts-for-gir/cli": "^3.2.2" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | PACK_FILE="space-bar@luchrioh.shell-extension.zip" 6 | 7 | function clear() ( 8 | if [ -d target ]; then 9 | rm -r target 10 | fi 11 | ) 12 | 13 | function compile() ( 14 | tsc --project tsconfig.build.json 15 | ) 16 | 17 | function fixupJavaScript() ( 18 | for file in $(find target -name '*.js'); do 19 | # Add .js suffix for relative imports. 20 | sed -i -E "s/^import (.*) from '(\.+.*)';$/import \1 from '\2.js';/g" "${file}" 21 | done 22 | ) 23 | 24 | function copyAdditionalFiles() ( 25 | cp -r src/schemas target/schemas 26 | 27 | for file in metadata.json README.md; do 28 | cp "$file" "target/$file" 29 | done 30 | 31 | ( 32 | cd src 33 | for file in stylesheet.css; do 34 | cp "$file" "../target/$file" 35 | done 36 | ) 37 | ) 38 | 39 | function pack() ( 40 | gnome-extensions pack target --force $( 41 | cd target 42 | for file in *; do echo "--extra-source=$file"; done 43 | ) 44 | echo "Packed $PACK_FILE" 45 | ) 46 | 47 | function install() ( 48 | gnome-extensions install --force "$PACK_FILE" 49 | echo "Installed $PACK_FILE" 50 | ) 51 | 52 | function main() ( 53 | cd "$(dirname ${BASH_SOURCE[0]})/.." 54 | clear 55 | compile 56 | fixupJavaScript 57 | copyAdditionalFiles 58 | pack 59 | while getopts i flag; do 60 | case $flag in 61 | i) install ;; 62 | esac 63 | done 64 | ) 65 | 66 | main "$@" 67 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | import { KeyBindings } from './services/KeyBindings'; 2 | import { ScrollHandler } from './services/ScrollHandler'; 3 | import { Settings } from './services/Settings'; 4 | import { Styles } from './services/Styles'; 5 | import { TopBarAdjustments } from './services/TopBarAdjustments'; 6 | import { Workspaces } from './services/Workspaces'; 7 | import { WorkspacesBar } from './ui/WorkspacesBar'; 8 | import { destroyAllHooks } from './utils/hook'; 9 | import { Extension } from 'resource:///org/gnome/shell/extensions/extension.js'; 10 | 11 | export default class SpaceBarExtension extends Extension { 12 | private workspacesBar: WorkspacesBar | null = null; 13 | private scrollHandler: ScrollHandler | null = null; 14 | 15 | enable() { 16 | Settings.init(this); 17 | TopBarAdjustments.init(); 18 | Workspaces.init(); 19 | KeyBindings.init(); 20 | Styles.init(); 21 | this.workspacesBar = new WorkspacesBar(this); 22 | this.workspacesBar.init(); 23 | this.scrollHandler = new ScrollHandler(); 24 | this.scrollHandler.init(this.workspacesBar.observeWidget()); 25 | } 26 | 27 | disable() { 28 | destroyAllHooks(); 29 | Settings.destroy(); 30 | TopBarAdjustments.destroy(); 31 | Workspaces.destroy(); 32 | KeyBindings.destroy(); 33 | Styles.destroy(); 34 | this.scrollHandler?.destroy(); 35 | this.scrollHandler = null; 36 | this.workspacesBar?.destroy(); 37 | this.workspacesBar = null; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/preferences/AppearancePage.ts: -------------------------------------------------------------------------------- 1 | import Adw from 'gi://Adw'; 2 | import Gio from 'gi://Gio'; 3 | import { addColorButton, addCombo, addSpinButton } from './common'; 4 | import { addCustomCssDialogButton } from './custom-styles'; 5 | 6 | export const fontWeightOptions = { 7 | '100': 'Thin', 8 | '200': 'Extra Light', 9 | '300': 'Light', 10 | '400': 'Normal', 11 | '500': 'Medium', 12 | '600': 'Semi Bold', 13 | '700': 'Bold', 14 | '800': 'Extra Bold', 15 | '900': 'Black', 16 | }; 17 | 18 | export class AppearancePage { 19 | window!: Adw.PreferencesWindow; 20 | readonly page = new Adw.PreferencesPage(); 21 | private readonly _settings: Gio.Settings; 22 | 23 | constructor(private _extensionPreferences: any) { 24 | this._settings = _extensionPreferences.getSettings( 25 | `org.gnome.shell.extensions.space-bar.appearance`, 26 | ); 27 | } 28 | 29 | init() { 30 | this.page.set_title('_Appearance'); 31 | this.page.useUnderline = true; 32 | this.page.set_icon_name('applications-graphics-symbolic'); 33 | this._connectEnabledConditions(); 34 | this._initGeneralGroup(); 35 | this._initActiveWorkspaceGroup(); 36 | this._initInactiveWorkspaceGroup(); 37 | this._initEmptyWorkspaceGroup(); 38 | this._initCustomStylesGroup(); 39 | } 40 | 41 | private _connectEnabledConditions() { 42 | const behaviorSettings = this._extensionPreferences.getSettings( 43 | `org.gnome.shell.extensions.space-bar.behavior`, 44 | ); 45 | const disabledNoticeGroup = new Adw.PreferencesGroup({ 46 | description: 47 | 'Appearance preferences currently support the indicator style "Workspaces bar" only.', 48 | }); 49 | this.page.add(disabledNoticeGroup); 50 | const updateEnabled = () => { 51 | const indicatorStyle = behaviorSettings.get_string(`indicator-style`); 52 | if (indicatorStyle === 'workspaces-bar') { 53 | this.page.set_sensitive(true); 54 | disabledNoticeGroup.set_visible(false); 55 | } else { 56 | this.page.set_sensitive(false); 57 | disabledNoticeGroup.set_visible(true); 58 | } 59 | }; 60 | updateEnabled(); 61 | const changed = behaviorSettings.connect(`changed::indicator-style`, updateEnabled); 62 | this.page.connect('unmap', () => behaviorSettings.disconnect(changed)); 63 | } 64 | 65 | private _initGeneralGroup(): void { 66 | const group = new Adw.PreferencesGroup(); 67 | group.set_title('General'); 68 | addSpinButton({ 69 | settings: this._settings, 70 | group, 71 | key: 'workspaces-bar-padding', 72 | title: 'Workspaces-bar padding', 73 | lower: 0, 74 | upper: 255, 75 | }).addResetButton({ window: this.window }); 76 | addSpinButton({ 77 | settings: this._settings, 78 | group, 79 | key: 'workspace-margin', 80 | title: 'Workspace margin', 81 | lower: 0, 82 | upper: 255, 83 | }).addResetButton({ window: this.window }); 84 | this.page.add(group); 85 | } 86 | 87 | private _initActiveWorkspaceGroup(): void { 88 | const group = new Adw.PreferencesGroup(); 89 | group.set_title('Active Workspace'); 90 | addColorButton({ 91 | window: this.window, 92 | settings: this._settings, 93 | group, 94 | key: 'active-workspace-background-color', 95 | title: 'Background color', 96 | }).addResetButton({ window: this.window }); 97 | addColorButton({ 98 | window: this.window, 99 | settings: this._settings, 100 | group, 101 | key: 'active-workspace-text-color', 102 | title: 'Text color', 103 | }).addResetButton({ window: this.window }); 104 | addColorButton({ 105 | window: this.window, 106 | settings: this._settings, 107 | group, 108 | key: 'active-workspace-border-color', 109 | title: 'Border color', 110 | }).addResetButton({ window: this.window }); 111 | addSpinButton({ 112 | settings: this._settings, 113 | group, 114 | key: 'active-workspace-font-size', 115 | title: 'Font size', 116 | lower: 0, 117 | upper: 255, 118 | }).addToggleButton({ window: this.window }); 119 | addCombo({ 120 | window: this.window, 121 | settings: this._settings, 122 | group, 123 | key: 'active-workspace-font-weight', 124 | title: 'Font weight', 125 | options: fontWeightOptions, 126 | }).addResetButton({ window: this.window }); 127 | addSpinButton({ 128 | settings: this._settings, 129 | group, 130 | key: 'active-workspace-border-radius', 131 | title: 'Border radius', 132 | lower: 0, 133 | upper: 255, 134 | }).addResetButton({ window: this.window }); 135 | addSpinButton({ 136 | settings: this._settings, 137 | group, 138 | key: 'active-workspace-border-width', 139 | title: 'Border width', 140 | lower: 0, 141 | upper: 255, 142 | }).addResetButton({ window: this.window }); 143 | addSpinButton({ 144 | settings: this._settings, 145 | group, 146 | key: 'active-workspace-padding-h', 147 | title: 'Horizontal padding', 148 | lower: 0, 149 | upper: 255, 150 | }).addResetButton({ window: this.window }); 151 | addSpinButton({ 152 | settings: this._settings, 153 | group, 154 | key: 'active-workspace-padding-v', 155 | title: 'Vertical padding', 156 | lower: 0, 157 | upper: 255, 158 | }).addResetButton({ window: this.window }); 159 | this.page.add(group); 160 | } 161 | 162 | private _initInactiveWorkspaceGroup(): void { 163 | const group = new Adw.PreferencesGroup(); 164 | group.set_title('Inactive Workspace'); 165 | addColorButton({ 166 | window: this.window, 167 | settings: this._settings, 168 | group, 169 | key: 'inactive-workspace-background-color', 170 | title: 'Background color', 171 | }).addResetButton({ window: this.window }); 172 | addColorButton({ 173 | window: this.window, 174 | settings: this._settings, 175 | group, 176 | key: 'inactive-workspace-text-color', 177 | title: 'Text color', 178 | }).addResetButton({ window: this.window }); 179 | addColorButton({ 180 | window: this.window, 181 | settings: this._settings, 182 | group, 183 | key: 'inactive-workspace-border-color', 184 | title: 'Border color', 185 | }).addResetButton({ window: this.window }); 186 | addSpinButton({ 187 | settings: this._settings, 188 | group, 189 | key: 'inactive-workspace-font-size', 190 | title: 'Font size', 191 | lower: 0, 192 | upper: 255, 193 | }).linkValue({ 194 | window: this.window, 195 | linkedKey: 'active-workspace-font-size', 196 | }); 197 | addCombo({ 198 | window: this.window, 199 | settings: this._settings, 200 | group, 201 | key: 'inactive-workspace-font-weight', 202 | title: 'Font weight', 203 | options: fontWeightOptions, 204 | }).linkValue({ 205 | window: this.window, 206 | linkedKey: 'active-workspace-font-weight', 207 | }); 208 | addSpinButton({ 209 | settings: this._settings, 210 | group, 211 | key: 'inactive-workspace-border-radius', 212 | title: 'Border radius', 213 | lower: 0, 214 | upper: 255, 215 | }).linkValue({ 216 | window: this.window, 217 | linkedKey: 'active-workspace-border-radius', 218 | }); 219 | addSpinButton({ 220 | settings: this._settings, 221 | group, 222 | key: 'inactive-workspace-border-width', 223 | title: 'Border width', 224 | lower: 0, 225 | upper: 255, 226 | }).linkValue({ 227 | window: this.window, 228 | linkedKey: 'active-workspace-border-width', 229 | }); 230 | addSpinButton({ 231 | settings: this._settings, 232 | group, 233 | key: 'inactive-workspace-padding-h', 234 | title: 'Horizontal padding', 235 | lower: 0, 236 | upper: 255, 237 | }).linkValue({ 238 | window: this.window, 239 | linkedKey: 'active-workspace-padding-h', 240 | }); 241 | addSpinButton({ 242 | settings: this._settings, 243 | group, 244 | key: 'inactive-workspace-padding-v', 245 | title: 'Vertical padding', 246 | lower: 0, 247 | upper: 255, 248 | }).linkValue({ 249 | window: this.window, 250 | linkedKey: 'active-workspace-padding-v', 251 | }); 252 | this.page.add(group); 253 | } 254 | 255 | private _initEmptyWorkspaceGroup(): void { 256 | const group = new Adw.PreferencesGroup(); 257 | group.set_title('Empty Workspace'); 258 | addColorButton({ 259 | window: this.window, 260 | settings: this._settings, 261 | group, 262 | key: 'empty-workspace-background-color', 263 | title: 'Background color', 264 | }).addResetButton({ window: this.window }); 265 | addColorButton({ 266 | window: this.window, 267 | settings: this._settings, 268 | group, 269 | key: 'empty-workspace-text-color', 270 | title: 'Text color', 271 | }).addResetButton({ window: this.window }); 272 | addColorButton({ 273 | window: this.window, 274 | settings: this._settings, 275 | group, 276 | key: 'empty-workspace-border-color', 277 | title: 'Border color', 278 | }).addResetButton({ window: this.window }); 279 | addSpinButton({ 280 | settings: this._settings, 281 | group, 282 | key: 'empty-workspace-font-size', 283 | title: 'Font size', 284 | lower: 0, 285 | upper: 255, 286 | }).linkValue({ 287 | window: this.window, 288 | linkedKey: 'inactive-workspace-font-size', 289 | }); 290 | addCombo({ 291 | window: this.window, 292 | settings: this._settings, 293 | group, 294 | key: 'empty-workspace-font-weight', 295 | title: 'Font weight', 296 | options: fontWeightOptions, 297 | }).linkValue({ 298 | window: this.window, 299 | linkedKey: 'inactive-workspace-font-weight', 300 | }); 301 | addSpinButton({ 302 | settings: this._settings, 303 | group, 304 | key: 'empty-workspace-border-radius', 305 | title: 'Border radius', 306 | lower: 0, 307 | upper: 255, 308 | }).linkValue({ 309 | window: this.window, 310 | linkedKey: 'inactive-workspace-border-radius', 311 | }); 312 | addSpinButton({ 313 | settings: this._settings, 314 | group, 315 | key: 'empty-workspace-border-width', 316 | title: 'Border width', 317 | lower: 0, 318 | upper: 255, 319 | }).linkValue({ 320 | window: this.window, 321 | linkedKey: 'inactive-workspace-border-width', 322 | }); 323 | addSpinButton({ 324 | settings: this._settings, 325 | group, 326 | key: 'empty-workspace-padding-h', 327 | title: 'Horizontal padding', 328 | lower: 0, 329 | upper: 255, 330 | }).linkValue({ 331 | window: this.window, 332 | linkedKey: 'inactive-workspace-padding-h', 333 | }); 334 | addSpinButton({ 335 | settings: this._settings, 336 | group, 337 | key: 'empty-workspace-padding-v', 338 | title: 'Vertical padding', 339 | lower: 0, 340 | upper: 255, 341 | }).linkValue({ 342 | window: this.window, 343 | linkedKey: 'inactive-workspace-padding-v', 344 | }); 345 | this.page.add(group); 346 | } 347 | 348 | private _initCustomStylesGroup(): void { 349 | const group = new Adw.PreferencesGroup(); 350 | group.set_title('Custom Styles'); 351 | addCustomCssDialogButton({ 352 | window: this.window, 353 | group, 354 | settings: this._settings, 355 | }); 356 | this.page.add(group); 357 | } 358 | } 359 | -------------------------------------------------------------------------------- /src/preferences/BehaviorPage.ts: -------------------------------------------------------------------------------- 1 | import Adw from 'gi://Adw'; 2 | import Gio from 'gi://Gio'; 3 | import { addCombo, addLinkButton, addSpinButton, addTextEntry, addToggle } from './common'; 4 | 5 | export const indicatorStyleOptions = { 6 | 'current-workspace': 'Current workspace', 7 | 'workspaces-bar': 'Workspaces bar', 8 | }; 9 | 10 | export const scrollWheelOptions = { 11 | panel: 'Over top panel', 12 | 'workspaces-bar': 'Over indicator', 13 | disabled: 'Disabled', 14 | }; 15 | 16 | export const scrollWheelDirectionOptions = { 17 | normal: 'Normal', 18 | inverted: 'Inverted', 19 | disabled: 'Disabled', 20 | }; 21 | 22 | export const positionOptions = { 23 | left: 'Left', 24 | center: 'Center', 25 | right: 'Right', 26 | }; 27 | 28 | export class BehaviorPage { 29 | window!: Adw.PreferencesWindow; 30 | readonly page = new Adw.PreferencesPage(); 31 | private readonly _settings: Gio.Settings; 32 | 33 | constructor(extensionPreferences: any) { 34 | this._settings = extensionPreferences.getSettings( 35 | `org.gnome.shell.extensions.space-bar.behavior`, 36 | ); 37 | } 38 | 39 | init() { 40 | this.page.set_title('_Behavior'); 41 | this.page.useUnderline = true; 42 | this.page.set_icon_name('preferences-system-symbolic'); 43 | this._initGeneralGroup(); 44 | this._initSmartWorkspaceNamesGroup(); 45 | } 46 | 47 | private _initGeneralGroup(): void { 48 | const group = new Adw.PreferencesGroup(); 49 | group.set_title('General'); 50 | addCombo({ 51 | window: this.window, 52 | settings: this._settings, 53 | group, 54 | key: 'indicator-style', 55 | title: 'Indicator style', 56 | options: indicatorStyleOptions, 57 | }).addSubDialog({ 58 | window: this.window, 59 | title: 'Indicator style', 60 | populatePage: (page) => { 61 | const group = new Adw.PreferencesGroup(); 62 | group.set_title('Custom label text'); 63 | group.set_description( 64 | 'Custom labels to use for workspace names in the top panel. The following placeholders will be replaced with their respective value:\n\n' + 65 | '{{name}}: The current workspace name\n' + 66 | '{{number}}: The current workspace number\n' + 67 | '{{total}}: The number of total workspaces\n' + 68 | '{{Total}}: The number of total workspaces, also counting the spare dynamic workspace', 69 | ); 70 | page.add(group); 71 | addToggle({ 72 | settings: this._settings, 73 | group, 74 | key: 'enable-custom-label', 75 | title: 'Use custom label text', 76 | }); 77 | addToggle({ 78 | settings: this._settings, 79 | group, 80 | key: 'enable-custom-label-in-menu', 81 | title: 'Also use custom label text in menu', 82 | }).enableIf({ 83 | key: 'enable-custom-label', 84 | predicate: (value) => value.get_boolean(), 85 | page, 86 | }); 87 | addTextEntry({ 88 | settings: this._settings, 89 | group, 90 | key: 'custom-label-named', 91 | title: 'Custom label for named workspaces', 92 | window: this.window, 93 | }).enableIf({ 94 | key: 'enable-custom-label', 95 | predicate: (value) => value.get_boolean(), 96 | page, 97 | }); 98 | addTextEntry({ 99 | settings: this._settings, 100 | group, 101 | key: 'custom-label-unnamed', 102 | title: 'Custom label for unnamed workspaces', 103 | window: this.window, 104 | }).enableIf({ 105 | key: 'enable-custom-label', 106 | predicate: (value) => value.get_boolean(), 107 | page, 108 | }); 109 | }, 110 | }); 111 | addCombo({ 112 | window: this.window, 113 | settings: this._settings, 114 | group, 115 | key: 'position', 116 | title: 'Position in top panel', 117 | options: positionOptions, 118 | }).addSubDialog({ 119 | window: this.window, 120 | title: 'Position in Top Panel', 121 | populatePage: (page) => { 122 | const positionSubDialogGroup = new Adw.PreferencesGroup(); 123 | page.add(positionSubDialogGroup); 124 | addToggle({ 125 | settings: this._settings, 126 | group: positionSubDialogGroup, 127 | key: 'system-workspace-indicator', 128 | title: 'Keep system workspace indicator', 129 | }); 130 | addSpinButton({ 131 | settings: this._settings, 132 | group: positionSubDialogGroup, 133 | key: 'position-index', 134 | title: 'Position index', 135 | subtitle: 'Order relative to other elements', 136 | lower: 0, 137 | upper: 100, 138 | }); 139 | }, 140 | }); 141 | addCombo({ 142 | window: this.window, 143 | settings: this._settings, 144 | group, 145 | key: 'scroll-wheel', 146 | title: 'Switch workspaces with scroll wheel', 147 | options: scrollWheelOptions, 148 | }).addSubDialog({ 149 | window: this.window, 150 | title: 'Switch Workspaces With Scroll Wheel', 151 | enableIf: { 152 | key: 'scroll-wheel', 153 | predicate: (value) => value.get_string()[0] !== 'disabled', 154 | page: this.page, 155 | }, 156 | populatePage: (page) => { 157 | const group = new Adw.PreferencesGroup(); 158 | page.add(group); 159 | addToggle({ 160 | settings: this._settings, 161 | group, 162 | key: 'scroll-wheel-debounce', 163 | title: 'Debounce scroll events', 164 | }); 165 | addSpinButton({ 166 | settings: this._settings, 167 | group, 168 | key: 'scroll-wheel-debounce-time', 169 | title: 'Debounce time (ms)', 170 | lower: 0, 171 | upper: 2000, 172 | step: 50, 173 | }).enableIf({ 174 | key: 'scroll-wheel-debounce', 175 | predicate: (value) => value.get_boolean(), 176 | page, 177 | }); 178 | addCombo({ 179 | window: this.window, 180 | settings: this._settings, 181 | group, 182 | key: 'scroll-wheel-vertical', 183 | title: 'Vertical scrolling', 184 | options: scrollWheelDirectionOptions, 185 | }); 186 | addCombo({ 187 | window: this.window, 188 | settings: this._settings, 189 | group, 190 | key: 'scroll-wheel-horizontal', 191 | title: 'Horizontal scrolling', 192 | options: scrollWheelDirectionOptions, 193 | }); 194 | addToggle({ 195 | settings: this._settings, 196 | group, 197 | key: 'scroll-wheel-wrap-around', 198 | title: 'Wrap around', 199 | }); 200 | }, 201 | }); 202 | addToggle({ 203 | settings: this._settings, 204 | group, 205 | key: 'always-show-numbers', 206 | title: 'Always show workspace numbers', 207 | }); 208 | addToggle({ 209 | settings: this._settings, 210 | group, 211 | key: 'show-empty-workspaces', 212 | title: 'Show empty workspaces', 213 | subtitle: 'Also affects switching with scroll wheel', 214 | }); 215 | addToggle({ 216 | settings: this._settings, 217 | group, 218 | key: 'toggle-overview', 219 | title: 'Toggle overview', 220 | subtitle: 'When clicking on the active or an empty workspace', 221 | }); 222 | this.page.add(group); 223 | } 224 | 225 | private _initSmartWorkspaceNamesGroup(): void { 226 | const group = new Adw.PreferencesGroup(); 227 | group.set_title('Smart Workspace Names'); 228 | group.set_description( 229 | 'Remembers open applications when renaming a workspace and automatically assigns workspace names based on the first application started on a new workspace.', 230 | ); 231 | addToggle({ 232 | settings: this._settings, 233 | group, 234 | key: 'smart-workspace-names', 235 | title: 'Enable smart workspace names', 236 | }).addSubDialog({ 237 | window: this.window, 238 | title: 'Smart Workspace Names', 239 | enableIf: { 240 | key: 'smart-workspace-names', 241 | predicate: (value) => value.get_boolean(), 242 | page: this.page, 243 | }, 244 | iconName: 'applications-science-symbolic', 245 | populatePage: (page) => { 246 | const group = new Adw.PreferencesGroup(); 247 | page.add(group); 248 | group.set_title('Re-evaluate names'); 249 | group.set_description( 250 | 'Removes workspace names when windows by which the name was assigned move away or close.\n\n' + 251 | 'Please leave feedback how you like the feature using the button below.', 252 | ); 253 | addToggle({ 254 | settings: this._settings, 255 | group, 256 | key: 'reevaluate-smart-workspace-names', 257 | title: 'Re-evaluate smart workspace names', 258 | }); 259 | addLinkButton({ 260 | title: 'Leave feedback', 261 | uri: 'https://github.com/christopher-l/space-bar/issues/37', 262 | group, 263 | }); 264 | }, 265 | }); 266 | this.page.add(group); 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /src/preferences/DropDownChoice.ts: -------------------------------------------------------------------------------- 1 | // Adapted from 2 | // https://gitlab.com/rmnvgr/nightthemeswitcher-gnome-shell-extension/-/blob/main/src/preferences/DropDownChoice.js 3 | 4 | // FIXME: There is probably some standard object we could use for this. 5 | 6 | import GObject from 'gi://GObject'; 7 | 8 | export declare class DropDownChoiceClass extends GObject.Object { 9 | id: string; 10 | title: string; 11 | constructor(params: { id: string; title: string }); 12 | } 13 | 14 | export const DropDownChoice = GObject.registerClass( 15 | { 16 | GTypeName: 'SpaceBarDropDownChoice', 17 | Properties: { 18 | id: GObject.ParamSpec.string( 19 | 'id', 20 | 'ID', 21 | 'Identifier', 22 | GObject.ParamFlags.READWRITE, 23 | null, 24 | ), 25 | title: GObject.ParamSpec.string( 26 | 'title', 27 | 'Title', 28 | 'Displayed title', 29 | GObject.ParamFlags.READWRITE, 30 | null, 31 | ), 32 | }, 33 | }, 34 | class DropDownChoice extends GObject.Object {}, 35 | ) as typeof DropDownChoiceClass & GObject.GType & GObject.Object; 36 | -------------------------------------------------------------------------------- /src/preferences/ShortcutsPage.ts: -------------------------------------------------------------------------------- 1 | import Adw from 'gi://Adw'; 2 | import Gio from 'gi://Gio'; 3 | import { addKeyboardShortcut, addToggle } from './common'; 4 | 5 | export class ShortcutsPage { 6 | window!: Adw.PreferencesWindow; 7 | readonly page = new Adw.PreferencesPage(); 8 | private readonly _settings: Gio.Settings; 9 | 10 | constructor(extensionPreferences: any) { 11 | this._settings = extensionPreferences.getSettings( 12 | `org.gnome.shell.extensions.space-bar.shortcuts`, 13 | ); 14 | } 15 | 16 | init() { 17 | this.page.set_title('_Shortcuts'); 18 | this.page.useUnderline = true; 19 | this.page.set_icon_name('preferences-desktop-keyboard-shortcuts-symbolic'); 20 | this._initGroup(); 21 | } 22 | 23 | private _initGroup(): void { 24 | const group = new Adw.PreferencesGroup(); 25 | group.set_description('Shortcuts might not work if they are already bound elsewhere.'); 26 | this.page.add(group); 27 | 28 | addToggle({ 29 | settings: this._settings, 30 | group, 31 | key: 'enable-activate-workspace-shortcuts', 32 | title: 'Switch to workspace', 33 | shortcutLabel: '1...0', 34 | }).addSubDialog({ 35 | window: this.window, 36 | title: 'Switch To Workspace', 37 | populatePage: (page) => { 38 | const group = new Adw.PreferencesGroup(); 39 | page.add(group); 40 | group.set_title('Back and forth'); 41 | group.set_description( 42 | 'Switch to the previous workspace by activating the shortcut for the current workspace again.\n\n' + 43 | 'Switch off "Toggle overview" in behavior settings to also enable this behavior when clicking the workspace using the mouse.', 44 | ); 45 | addToggle({ 46 | settings: this._settings, 47 | group, 48 | key: 'back-and-forth', 49 | title: 'Back and forth', 50 | }); 51 | }, 52 | }); 53 | 54 | addToggle({ 55 | settings: this._settings, 56 | group, 57 | key: 'enable-move-to-workspace-shortcuts', 58 | title: 'Move to workspace', 59 | shortcutLabel: '1...0', 60 | subtitle: 'With the current window', 61 | }); 62 | 63 | addKeyboardShortcut({ 64 | settings: this._settings, 65 | window: this.window, 66 | group, 67 | key: 'move-workspace-left', 68 | title: 'Move current workspace left', 69 | }); 70 | 71 | addKeyboardShortcut({ 72 | settings: this._settings, 73 | window: this.window, 74 | group, 75 | key: 'move-workspace-right', 76 | title: 'Move current workspace right', 77 | }); 78 | 79 | addKeyboardShortcut({ 80 | settings: this._settings, 81 | window: this.window, 82 | group, 83 | key: 'activate-previous-key', 84 | title: 'Switch to previous workspace', 85 | }); 86 | 87 | addKeyboardShortcut({ 88 | settings: this._settings, 89 | window: this.window, 90 | group, 91 | key: 'activate-empty-key', 92 | title: 'Switch to empty workspace', 93 | subtitle: 'Adds new workspace if needed', 94 | }); 95 | 96 | addKeyboardShortcut({ 97 | settings: this._settings, 98 | window: this.window, 99 | group, 100 | key: 'open-menu', 101 | title: 'Open menu', 102 | }); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/preferences/common.ts: -------------------------------------------------------------------------------- 1 | import Adw from 'gi://Adw'; 2 | import Gdk from 'gi://Gdk'; 3 | import Gio from 'gi://Gio'; 4 | import GLib from 'gi://GLib'; 5 | import GObject from 'gi://GObject'; 6 | import Gtk from 'gi://Gtk'; 7 | import { DropDownChoice, DropDownChoiceClass } from './DropDownChoice'; 8 | 9 | interface EnableCondition { 10 | key: string; 11 | predicate: (value: GLib.Variant) => boolean; 12 | page: Adw.PreferencesPage; 13 | } 14 | 15 | class PreferencesRow { 16 | constructor( 17 | private readonly _settings: Gio.Settings, 18 | private readonly _row: Adw.ActionRow, 19 | private readonly _key: string, 20 | private readonly _setEnabledInner?: (value: boolean) => void, 21 | ) {} 22 | 23 | enableIf({ key, predicate, page }: EnableCondition): void { 24 | const updateEnabled = () => { 25 | const value = this._settings.get_value(key); 26 | this._row.set_sensitive(predicate(value)); 27 | }; 28 | updateEnabled(); 29 | const changed = this._settings.connect(`changed::${key}`, updateEnabled); 30 | page.connect('unmap', () => this._settings.disconnect(changed)); 31 | } 32 | 33 | addResetButton({ window }: { window: Adw.PreferencesWindow }): void { 34 | const button = new Gtk.Button({ 35 | iconName: 'edit-clear-symbolic', 36 | valign: Gtk.Align.CENTER, 37 | hasFrame: false, 38 | marginStart: 10, 39 | }); 40 | button.connect('clicked', () => this._settings.reset(this._key)); 41 | const updateButton = () => { 42 | const buttonEnabled = this._settings.get_user_value(this._key) !== null; 43 | button.set_sensitive(buttonEnabled); 44 | }; 45 | updateButton(); 46 | const changed = this._settings.connect(`changed::${this._key}`, updateButton); 47 | window.connect('unmap', () => this._settings.disconnect(changed)); 48 | this._row.add_suffix(button); 49 | } 50 | 51 | /** 52 | * Adds a toggle button to the row that enables / disables the setting. 53 | * 54 | * When disabled, the setting is reset to its default value. 55 | * 56 | * When enabled, the setting is set to the value of -user. The value -user is updated 57 | * to the current value as long as the setting is enabled. 58 | */ 59 | addToggleButton({ window }: { window: Adw.PreferencesWindow }): void { 60 | const activeKey = this._key + '-active'; 61 | const userKey = this._key + '-user'; 62 | const toggleEdit = (active: boolean) => { 63 | this._settings.set_boolean(activeKey, active); 64 | updateRow(); 65 | updateValue(); 66 | }; 67 | const updateRow = () => { 68 | const active = this._settings.get_boolean(activeKey); 69 | this._setEnabled(active); 70 | }; 71 | const updateValue = () => { 72 | const active = this._settings.get_boolean(activeKey); 73 | if (active) { 74 | const userValue = this._settings.get_value(userKey); 75 | this._settings.set_value(this._key, userValue); 76 | } else { 77 | this._settings.reset(this._key); 78 | } 79 | }; 80 | const updateUserValue = () => { 81 | const active = this._settings.get_boolean(activeKey); 82 | if (active) { 83 | const value = this._settings.get_value(this._key); 84 | this._settings.set_value(userKey, value); 85 | } 86 | }; 87 | const changed = this._settings.connect(`changed::${this._key}`, updateUserValue); 88 | window.connect('unmap', () => this._settings.disconnect(changed)); 89 | updateRow(); 90 | const button = new Gtk.ToggleButton({ 91 | iconName: 'document-edit-symbolic', 92 | valign: Gtk.Align.CENTER, 93 | hasFrame: false, 94 | marginStart: 10, 95 | }); 96 | button.connect('toggled', (toggle: Gtk.ToggleButton) => toggleEdit(toggle.active)); 97 | this._row.add_suffix(button); 98 | } 99 | 100 | linkValue({ 101 | linkedKey, 102 | activeKey = this._key + '-active', 103 | window, 104 | }: { 105 | linkedKey: string; 106 | activeKey?: string; 107 | window: Adw.PreferencesWindow; 108 | }): void { 109 | const toggleEdit = (active: boolean) => { 110 | this._settings.set_boolean(activeKey, active); 111 | updateRow(); 112 | updateLinkedValue(); 113 | }; 114 | const updateRow = () => { 115 | const active = this._settings.get_boolean(activeKey); 116 | this._setEnabled(active); 117 | }; 118 | const updateLinkedValue = () => { 119 | const active = this._settings.get_boolean(activeKey); 120 | if (!active) { 121 | const linkedValue = this._settings.get_user_value(linkedKey); 122 | if (linkedValue) { 123 | this._settings.set_value(this._key, linkedValue); 124 | } else { 125 | this._settings.reset(this._key); 126 | } 127 | } 128 | }; 129 | const changed = this._settings.connect(`changed::${linkedKey}`, updateLinkedValue); 130 | window.connect('unmap', () => this._settings.disconnect(changed)); 131 | updateRow(); 132 | const button = new Gtk.ToggleButton({ 133 | iconName: 'document-edit-symbolic', 134 | valign: Gtk.Align.CENTER, 135 | hasFrame: false, 136 | marginStart: 10, 137 | }); 138 | button.connect('toggled', (toggle: Gtk.ToggleButton) => toggleEdit(toggle.active)); 139 | this._row.add_suffix(button); 140 | } 141 | 142 | addSubDialog({ 143 | window, 144 | title, 145 | populatePage, 146 | enableIf, 147 | iconName = 'applications-system-symbolic', 148 | }: { 149 | window: Adw.PreferencesWindow; 150 | title: string; 151 | populatePage: (page: Adw.PreferencesPage) => void; 152 | enableIf?: EnableCondition; 153 | iconName?: string; 154 | }): void { 155 | function showDialog() { 156 | const dialog = new Gtk.Dialog({ 157 | title, 158 | modal: true, 159 | useHeaderBar: 1, 160 | transientFor: window, 161 | widthRequest: 350, 162 | defaultWidth: 500, 163 | }); 164 | const page = new Adw.PreferencesPage(); 165 | populatePage(page); 166 | dialog.set_child(page); 167 | dialog.show(); 168 | } 169 | const button = new Gtk.Button({ 170 | iconName, 171 | valign: Gtk.Align.CENTER, 172 | hasFrame: false, 173 | }); 174 | button.connect('clicked', () => showDialog()); 175 | this._row.add_suffix( 176 | new Gtk.Separator({ 177 | marginStart: 12, 178 | marginEnd: 4, 179 | marginTop: 12, 180 | marginBottom: 12, 181 | }), 182 | ); 183 | this._row.add_suffix(button); 184 | if (enableIf) { 185 | const updateEnabled = () => { 186 | const value = this._settings.get_value(enableIf.key); 187 | button.set_sensitive(enableIf.predicate(value)); 188 | }; 189 | updateEnabled(); 190 | const changed = this._settings.connect(`changed::${enableIf.key}`, updateEnabled); 191 | enableIf.page.connect('unmap', () => this._settings.disconnect(changed)); 192 | } 193 | } 194 | 195 | private _setEnabled(value: boolean): void { 196 | this._setEnabledInner?.(value); 197 | } 198 | } 199 | 200 | export function addToggle({ 201 | group, 202 | key, 203 | title, 204 | subtitle = null, 205 | settings, 206 | shortcutLabel, 207 | }: { 208 | group: Adw.PreferencesGroup; 209 | key: string; 210 | title: string; 211 | subtitle?: string | null; 212 | settings: Gio.Settings; 213 | shortcutLabel?: string | null; 214 | }): PreferencesRow { 215 | const row = new Adw.ActionRow({ title, subtitle }); 216 | group.add(row); 217 | 218 | if (shortcutLabel) { 219 | const gtkShortcutLabel = new Gtk.ShortcutLabel({ 220 | accelerator: shortcutLabel, 221 | valign: Gtk.Align.CENTER, 222 | }); 223 | row.add_prefix(gtkShortcutLabel); 224 | } 225 | 226 | const toggle = new Gtk.Switch({ 227 | active: settings.get_boolean(key), 228 | valign: Gtk.Align.CENTER, 229 | }); 230 | settings.bind(key, toggle, 'active', Gio.SettingsBindFlags.DEFAULT); 231 | 232 | row.add_suffix(toggle); 233 | row.activatableWidget = toggle; 234 | return new PreferencesRow(settings, row, key, (enabled) => toggle.set_sensitive(enabled)); 235 | } 236 | 237 | export function addLinkButton({ 238 | group, 239 | title, 240 | subtitle = null, 241 | uri, 242 | }: { 243 | group: Adw.PreferencesGroup; 244 | title: string; 245 | subtitle?: string | null; 246 | uri: string; 247 | }): void { 248 | const row = new Adw.ActionRow({ title, subtitle }); 249 | group.add(row); 250 | const icon = new Gtk.Image({ iconName: 'adw-external-link-symbolic' }); 251 | row.set_activatable(true); 252 | row.connect('activated', () => Gtk.show_uri(null, uri, Gdk.CURRENT_TIME)); 253 | row.add_suffix(icon); 254 | } 255 | 256 | export function addTextEntry({ 257 | group, 258 | key, 259 | title, 260 | subtitle = null, 261 | settings, 262 | window, 263 | shortcutLabel, 264 | }: { 265 | group: Adw.PreferencesGroup; 266 | key: string; 267 | title: string; 268 | subtitle?: string | null; 269 | settings: Gio.Settings; 270 | window: Adw.PreferencesWindow; 271 | shortcutLabel?: string | null; 272 | }): PreferencesRow { 273 | const row = new Adw.ActionRow({ title, subtitle }); 274 | group.add(row); 275 | 276 | if (shortcutLabel) { 277 | const gtkShortcutLabel = new Gtk.ShortcutLabel({ 278 | accelerator: shortcutLabel, 279 | valign: Gtk.Align.CENTER, 280 | }); 281 | row.add_prefix(gtkShortcutLabel); 282 | } 283 | 284 | const entry = new Gtk.Entry({ 285 | text: settings.get_string(key), 286 | valign: Gtk.Align.CENTER, 287 | }); 288 | const focusController = new Gtk.EventControllerFocus(); 289 | focusController.connect('leave', () => { 290 | settings.set_string(key, entry.get_buffer().text!); 291 | }); 292 | entry.add_controller(focusController); 293 | const changed = settings.connect(`changed::${key}`, () => { 294 | entry.set_text(settings.get_string(key)!); 295 | }); 296 | window.connect('unmap', () => settings.disconnect(changed)); 297 | 298 | row.add_suffix(entry); 299 | row.activatableWidget = entry; 300 | return new PreferencesRow(settings, row, key, (enabled) => entry.set_sensitive(enabled)); 301 | } 302 | 303 | export function addCombo({ 304 | group, 305 | key, 306 | title, 307 | subtitle = null, 308 | options, 309 | settings, 310 | window, 311 | }: { 312 | group: Adw.PreferencesGroup; 313 | key: string; 314 | title: string; 315 | subtitle?: string | null; 316 | options: { [key: string]: string }; 317 | settings: Gio.Settings; 318 | window: Adw.PreferencesWindow; 319 | }): PreferencesRow { 320 | const model = Gio.ListStore.new(DropDownChoice); 321 | for (const id in options) { 322 | model.append(new DropDownChoice({ id, title: options[id] })); 323 | } 324 | const row = new Adw.ComboRow({ 325 | title, 326 | subtitle, 327 | model, 328 | expression: Gtk.PropertyExpression.new(DropDownChoice, null, 'title'), 329 | }); 330 | group.add(row); 331 | row.connect('notify::selected-item', () => { 332 | // This may trigger without user interaction, so we only update the value when it differs 333 | // from the the default value or a user value has been set before. 334 | const value = (row.selectedItem as DropDownChoiceClass).id; 335 | if (settings.get_user_value(key) !== null || settings.get_string(key) !== value) { 336 | settings.set_string(key, value); 337 | } 338 | }); 339 | function updateComboRowState() { 340 | row.selected = 341 | findItemPositionInModel( 342 | model, 343 | (item) => item.id === settings.get_string(key), 344 | ) ?? Gtk.INVALID_LIST_POSITION; 345 | } 346 | const changed = settings.connect(`changed::${key}`, () => updateComboRowState()); 347 | window.connect('unmap', () => settings.disconnect(changed)); 348 | updateComboRowState(); 349 | 350 | const suffixes = row.get_first_child()?.get_last_child(); 351 | const comboBoxElements = [suffixes?.get_first_child(), suffixes?.get_last_child()]; 352 | return new PreferencesRow(settings, row, key, (enabled) => { 353 | row.set_activatable(enabled); 354 | const opacity = enabled ? 1 : 0.5; 355 | comboBoxElements.forEach((el) => el?.set_opacity(opacity)); 356 | }); 357 | } 358 | 359 | export function addSpinButton({ 360 | group, 361 | key, 362 | title, 363 | subtitle = null, 364 | settings, 365 | lower, 366 | upper, 367 | step = 1, 368 | }: { 369 | group: Adw.PreferencesGroup; 370 | key: string; 371 | title: string; 372 | subtitle?: string | null; 373 | settings: Gio.Settings; 374 | lower: number; 375 | upper: number; 376 | step?: number | null; 377 | }): PreferencesRow { 378 | const row = new Adw.ActionRow({ title, subtitle }); 379 | group.add(row); 380 | 381 | const spinner = new Gtk.SpinButton({ 382 | adjustment: new Gtk.Adjustment({ 383 | stepIncrement: step ?? 1, 384 | lower, 385 | upper, 386 | }), 387 | value: settings.get_int(key), 388 | valign: Gtk.Align.CENTER, 389 | halign: Gtk.Align.CENTER, 390 | }); 391 | 392 | settings.bind(key, spinner, 'value', Gio.SettingsBindFlags.DEFAULT); 393 | 394 | row.add_suffix(spinner); 395 | row.activatableWidget = spinner; 396 | return new PreferencesRow(settings, row, key, (enabled) => { 397 | spinner.set_sensitive(enabled); 398 | }); 399 | } 400 | 401 | export function addColorButton({ 402 | group, 403 | key, 404 | title, 405 | subtitle = null, 406 | settings, 407 | window, 408 | }: { 409 | group: Adw.PreferencesGroup; 410 | key: string; 411 | title: string; 412 | subtitle?: string | null; 413 | settings: Gio.Settings; 414 | window: Adw.PreferencesWindow; 415 | }): PreferencesRow { 416 | const row = new Adw.ActionRow({ title, subtitle }); 417 | group.add(row); 418 | const colorButton = new Gtk.ColorButton({ 419 | valign: Gtk.Align.CENTER, 420 | useAlpha: true, 421 | }); 422 | const updateColorButton = () => { 423 | const color = new Gdk.RGBA(); 424 | color.parse(settings.get_string(key)!); 425 | colorButton.set_rgba(color); 426 | }; 427 | updateColorButton(); 428 | colorButton.connect('color-set', () => { 429 | const color = colorButton.rgba.to_string()!; 430 | settings.set_string(key, color); 431 | }); 432 | const changed = settings.connect(`changed::${key}`, updateColorButton); 433 | window.connect('unmap', () => settings.disconnect(changed)); 434 | row.add_suffix(colorButton); 435 | row.activatableWidget = colorButton; 436 | return new PreferencesRow(settings, row, key, (enabled) => colorButton.set_sensitive(enabled)); 437 | } 438 | 439 | export function addKeyboardShortcut({ 440 | window, 441 | group, 442 | key, 443 | title, 444 | subtitle = null, 445 | settings, 446 | }: { 447 | window: Adw.PreferencesWindow; 448 | group: Adw.PreferencesGroup; 449 | key: string; 450 | title: string; 451 | subtitle?: string | null; 452 | settings: Gio.Settings; 453 | }): void { 454 | const row = new Adw.ActionRow({ 455 | title, 456 | subtitle, 457 | activatable: true, 458 | }); 459 | group.add(row); 460 | 461 | const shortcutLabel = new Gtk.ShortcutLabel({ 462 | accelerator: settings.get_strv(key)[0] ?? null, 463 | valign: Gtk.Align.CENTER, 464 | }); 465 | row.add_suffix(shortcutLabel); 466 | const disabledLabel = new Gtk.Label({ 467 | label: 'Disabled', 468 | cssClasses: ['dim-label'], 469 | }); 470 | row.add_suffix(disabledLabel); 471 | if (settings.get_strv(key).length > 0) { 472 | disabledLabel.hide(); 473 | } else { 474 | shortcutLabel.hide(); 475 | } 476 | 477 | function showDialog(): void { 478 | const dialog = new Gtk.Dialog({ 479 | title: 'Set Shortcut', 480 | modal: true, 481 | useHeaderBar: 1, 482 | transientFor: window, 483 | widthRequest: 400, 484 | heightRequest: 200, 485 | }); 486 | const dialogBox = new Gtk.Box({ 487 | marginBottom: 12, 488 | marginEnd: 12, 489 | marginStart: 12, 490 | marginTop: 12, 491 | orientation: Gtk.Orientation.VERTICAL, 492 | valign: Gtk.Align.CENTER, 493 | }); 494 | const dialogLabel = new Gtk.Label({ 495 | label: 'Enter new shortcut to change ' + title + '.', 496 | useMarkup: true, 497 | marginBottom: 12, 498 | }); 499 | dialogBox.append(dialogLabel); 500 | const dialogDimLabel = new Gtk.Label({ 501 | label: 'Press Esc to cancel or Backspace to disable the keyboard shortcut.', 502 | cssClasses: ['dim-label'], 503 | }); 504 | dialogBox.append(dialogDimLabel); 505 | const keyController = new Gtk.EventControllerKey({ 506 | propagationPhase: Gtk.PropagationPhase.CAPTURE, 507 | }); 508 | dialog.add_controller(keyController); 509 | keyController.connect('key-pressed', (keyController, keyval, keycode, modifier) => { 510 | modifier = fixModifiers(modifier); 511 | const accelerator = getAccelerator(keyval, modifier); 512 | if (accelerator) { 513 | if (keyval === Gdk.KEY_Escape && !modifier) { 514 | // Just close the dialog 515 | } else if (keyval === Gdk.KEY_BackSpace && !modifier) { 516 | shortcutLabel.hide(); 517 | disabledLabel.show(); 518 | settings.set_strv(key, []); 519 | } else { 520 | shortcutLabel.accelerator = accelerator; 521 | shortcutLabel.show(); 522 | disabledLabel.hide(); 523 | settings.set_strv(key, [accelerator]); 524 | } 525 | dialog.close(); 526 | } 527 | }); 528 | dialog.set_child(dialogBox); 529 | dialog.show(); 530 | } 531 | 532 | row.connect('activated', () => showDialog()); 533 | } 534 | 535 | function getAccelerator(keyval: number, modifiers: number): string | null { 536 | const isValid = Gtk.accelerator_valid(keyval, modifiers); 537 | if (isValid) { 538 | const acceleratorName = Gtk.accelerator_name(keyval, modifiers); 539 | return acceleratorName; 540 | } else { 541 | return null; 542 | } 543 | } 544 | 545 | // From https://gitlab.com/rmnvgr/nightthemeswitcher-gnome-shell-extension/-/blob/main/src/utils.js 546 | function findItemPositionInModel( 547 | model: Gio.ListModel, 548 | predicate: (item: T) => boolean, 549 | ): number | undefined { 550 | for (let i = 0; i < model.get_n_items(); i++) { 551 | if (predicate(model.get_item(i) as T)) { 552 | return i; 553 | } 554 | } 555 | return undefined; 556 | } 557 | 558 | /** 559 | * Removes invalid modifier bits. 560 | */ 561 | function fixModifiers(modifiers: number): number { 562 | return ( 563 | modifiers & 564 | // Set by Xorg when holding the Super key in addition to the valid Meta modifier. 565 | ~64 & 566 | // Set when num lock is enabled. 567 | ~16 568 | ); 569 | } 570 | -------------------------------------------------------------------------------- /src/preferences/custom-styles.ts: -------------------------------------------------------------------------------- 1 | import Adw from 'gi://Adw'; 2 | import Gio from 'gi://Gio'; 3 | import Gtk from 'gi://Gtk'; 4 | 5 | export function addCustomCssDialogButton({ 6 | window, 7 | group, 8 | settings, 9 | }: { 10 | window: Adw.PreferencesWindow; 11 | group: Adw.PreferencesGroup; 12 | settings: Gio.Settings; 13 | }): void { 14 | const row = new Adw.ActionRow({ 15 | title: 'Custom styles', 16 | activatable: true, 17 | valign: Gtk.Align.CENTER, 18 | }); 19 | group.add(row); 20 | const enabledLabel = buildEnabledLabel({ settings, window }); 21 | row.add_suffix(enabledLabel); 22 | const icon = new Gtk.Image({ iconName: 'go-next-symbolic' }); 23 | row.add_suffix(icon); 24 | row.connect('activated', () => showDialog({ window, settings })); 25 | } 26 | 27 | function buildEnabledLabel({ 28 | settings, 29 | window, 30 | }: { 31 | settings: Gio.Settings; 32 | window: Adw.PreferencesWindow; 33 | }): Gtk.Label { 34 | const enabledLabel = new Gtk.Label(); 35 | function updateEnabledLabel(): void { 36 | if (settings.get_boolean('custom-styles-enabled')) { 37 | enabledLabel.set_label('On'); 38 | } else { 39 | enabledLabel.set_label('Off'); 40 | } 41 | } 42 | updateEnabledLabel(); 43 | const changed = settings.connect('changed::custom-styles-enabled', updateEnabledLabel); 44 | window.connect('unmap', () => settings.disconnect(changed)); 45 | return enabledLabel; 46 | } 47 | 48 | function showDialog({ 49 | window, 50 | settings, 51 | }: { 52 | window: Adw.PreferencesWindow; 53 | settings: Gio.Settings; 54 | }): void { 55 | const dialog = new Adw.Dialog({ 56 | title: 'Custom Styles', 57 | contentWidth: 600, 58 | contentHeight: 2000, 59 | }); 60 | const toastOverlay = new Adw.ToastOverlay(); 61 | const toolbarView = new Adw.ToolbarView(); 62 | toastOverlay.set_child(toolbarView); 63 | let savedCustomStyles = settings.get_string('custom-styles') ?? ''; 64 | let textViewCustomStyles = savedCustomStyles; 65 | function updateApplyButton(): void { 66 | const hasChanged = textViewCustomStyles !== savedCustomStyles; 67 | applyButton.set_sensitive(hasChanged); 68 | } 69 | function customStylesChanged(text: string): void { 70 | textViewCustomStyles = text; 71 | updateApplyButton(); 72 | } 73 | const { headerBar, applyButton } = buildHeaderBar({ 74 | settings, 75 | showAppStyles: () => { 76 | toolbarView.set_content(applicationStylesBox({ settings })); 77 | }, 78 | showCustomStyles: () => { 79 | toolbarView.set_content(customStylesBox({ settings, customStylesChanged })); 80 | }, 81 | }); 82 | updateApplyButton(); 83 | applyButton.connect('clicked', () => { 84 | const isEnabled = settings.get_boolean('custom-styles-enabled'); 85 | settings.set_string('custom-styles', textViewCustomStyles); 86 | // In case of invalid styles, custom-styles-enabled will be set to false 87 | // automatically when setting custom-styles. Only enable if it was 88 | // previously disabled. 89 | if (!isEnabled) { 90 | settings.set_boolean('custom-styles-enabled', true); 91 | } 92 | savedCustomStyles = textViewCustomStyles; 93 | updateApplyButton(); 94 | }); 95 | toolbarView.add_top_bar(headerBar); 96 | toolbarView.set_content(customStylesBox({ settings, customStylesChanged })); 97 | const unregisterFailedToast = registerFailedToast({ toastOverlay, settings }); 98 | dialog.connect('closed', unregisterFailedToast); 99 | dialog.set_child(toastOverlay); 100 | dialog.present(window); 101 | } 102 | 103 | function registerFailedToast({ 104 | toastOverlay, 105 | settings, 106 | }: { 107 | toastOverlay: Adw.ToastOverlay; 108 | settings: Gio.Settings; 109 | }): () => void { 110 | let toast: Adw.Toast; 111 | function showFailedToast(): void { 112 | toast?.dismiss(); 113 | if (settings.get_boolean('custom-styles-failed')) { 114 | toast = new Adw.Toast({ 115 | title: 'Failed to load styles. Custom styles have been disabled.', 116 | timeout: 3, 117 | }); 118 | toastOverlay.add_toast(toast); 119 | } 120 | } 121 | const showFailedToastConnection = settings.connect( 122 | `changed::custom-styles-failed`, 123 | showFailedToast, 124 | ); 125 | return () => settings.disconnect(showFailedToastConnection); 126 | } 127 | 128 | function buildHeaderBar({ 129 | settings, 130 | showAppStyles, 131 | showCustomStyles, 132 | }: { 133 | settings: Gio.Settings; 134 | showAppStyles: () => void; 135 | showCustomStyles: () => void; 136 | }): { headerBar: Adw.HeaderBar; applyButton: Gtk.Button } { 137 | const titleBox = new Gtk.Box({ 138 | spacing: 6, 139 | }); 140 | const appStylesButton = new Gtk.ToggleButton({ 141 | label: 'Application Styles', 142 | cssClasses: ['flat'], 143 | }); 144 | appStylesButton.connect('notify::active', (button) => button.active && showAppStyles()); 145 | const customStylesButton = new Gtk.ToggleButton({ 146 | label: 'Custom Styles', 147 | cssClasses: ['flat'], 148 | group: appStylesButton, 149 | active: true, 150 | }); 151 | customStylesButton.connect('notify::active', (button) => button.active && showCustomStyles()); 152 | titleBox.append(appStylesButton); 153 | titleBox.append(customStylesButton); 154 | const headerBar = new Adw.HeaderBar({ 155 | titleWidget: titleBox, 156 | }); 157 | const enabledToggle = new Gtk.Switch({ 158 | active: settings.get_boolean('custom-styles-enabled'), 159 | marginStart: 12, 160 | }); 161 | settings.bind('custom-styles-enabled', enabledToggle, 'active', Gio.SettingsBindFlags.DEFAULT); 162 | headerBar.pack_start(enabledToggle); 163 | const applyButton = new Gtk.Button({ 164 | label: 'Apply', 165 | cssClasses: ['suggested-action'], 166 | marginEnd: 4, 167 | }); 168 | headerBar.pack_end(applyButton); 169 | return { headerBar, applyButton }; 170 | } 171 | 172 | function applicationStylesBox({ settings }: { settings: Gio.Settings }): Gtk.Widget { 173 | const box = new Gtk.Box({ 174 | orientation: Gtk.Orientation.VERTICAL, 175 | halign: Gtk.Align.FILL, 176 | marginStart: 24, 177 | marginEnd: 24, 178 | marginTop: 12, 179 | marginBottom: 24, 180 | }); 181 | const descriptionLabel = new Gtk.Label({ 182 | label: 183 | "The application styles that are generated based on the extension's appearance preferences. " + 184 | 'You cannot change these styles here, but you can override them with custom styles.', 185 | cssClasses: ['description_label'], 186 | wrap: true, 187 | marginBottom: 12, 188 | xalign: 0, 189 | naturalWrapMode: Gtk.NaturalWrapMode.NONE, 190 | }); 191 | box.append(descriptionLabel); 192 | const frame = new Gtk.Frame({ 193 | vexpand: true, 194 | }); 195 | box.append(frame); 196 | const scrolled = new Gtk.ScrolledWindow({}); 197 | frame.set_child(scrolled); 198 | const textView = new Gtk.TextView({ 199 | editable: false, 200 | monospace: true, 201 | wrapMode: Gtk.WrapMode.WORD_CHAR, 202 | leftMargin: 12, 203 | rightMargin: 12, 204 | topMargin: 12, 205 | bottomMargin: 12, 206 | }); 207 | scrolled.set_child(textView); 208 | const text = settings.get_string('application-styles') ?? ''; 209 | textView.buffer.set_text(text, -1); 210 | return box; 211 | } 212 | 213 | function customStylesBox({ 214 | settings, 215 | customStylesChanged, 216 | }: { 217 | settings: Gio.Settings; 218 | customStylesChanged: (text: string) => void; 219 | }): Gtk.Widget { 220 | const box = new Gtk.Box({ 221 | orientation: Gtk.Orientation.VERTICAL, 222 | halign: Gtk.Align.FILL, 223 | marginStart: 24, 224 | marginEnd: 24, 225 | marginTop: 12, 226 | marginBottom: 24, 227 | }); 228 | const descriptionLabel = new Gtk.Label({ 229 | label: 'Add any custom styles to override application styles.', 230 | cssClasses: ['description_label'], 231 | wrap: true, 232 | marginBottom: 12, 233 | xalign: 0, 234 | naturalWrapMode: Gtk.NaturalWrapMode.NONE, 235 | }); 236 | box.append(descriptionLabel); 237 | const frame = new Gtk.Frame({ 238 | vexpand: true, 239 | }); 240 | box.append(frame); 241 | const scrolled = new Gtk.ScrolledWindow({}); 242 | frame.set_child(scrolled); 243 | const textView = new Gtk.TextView({ 244 | editable: true, 245 | monospace: true, 246 | cursorVisible: true, 247 | wrapMode: Gtk.WrapMode.WORD_CHAR, 248 | leftMargin: 12, 249 | rightMargin: 12, 250 | topMargin: 12, 251 | bottomMargin: 12, 252 | }); 253 | scrolled.set_child(textView); 254 | const initialText = settings.get_string('custom-styles') ?? ''; 255 | textView.buffer.set_text(initialText, -1); 256 | textView.buffer.connect('changed', () => { 257 | const text = 258 | textView.buffer.get_text( 259 | textView.buffer.get_start_iter(), 260 | textView.buffer.get_end_iter(), 261 | false, 262 | ) ?? ''; 263 | customStylesChanged(text); 264 | }); 265 | return box; 266 | } 267 | -------------------------------------------------------------------------------- /src/prefs.ts: -------------------------------------------------------------------------------- 1 | import type Adw from 'gi://Adw'; 2 | import { ExtensionPreferences } from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js'; 3 | import { AppearancePage } from './preferences/AppearancePage'; 4 | import { BehaviorPage } from './preferences/BehaviorPage'; 5 | import { ShortcutsPage } from './preferences/ShortcutsPage'; 6 | 7 | export default class SpaceBarExtensionPreferences extends ExtensionPreferences { 8 | fillPreferencesWindow(window: Adw.PreferencesWindow) { 9 | [new BehaviorPage(this), new AppearancePage(this), new ShortcutsPage(this)].forEach((pageObject) => { 10 | pageObject.window = window; 11 | pageObject.init(); 12 | window.add(pageObject.page); 13 | }); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/schemas/org.gnome.shell.extensions.space-bar.gschema.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 0 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | "workspaces-bar" 20 | Indicator style 21 | 22 | 23 | false 24 | 25 | 26 | false 27 | 28 | 29 | '{{name}}' 30 | 31 | 32 | 'Workspace {{number}}' 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | "left" 41 | Position in top panel 42 | 43 | 44 | false 45 | 46 | 47 | 48 | 1 49 | Position index 50 | Order relative to other elements 51 | 52 | 53 | false 54 | 55 | 56 | true 57 | 58 | 59 | true 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | "panel" 68 | Switch workspaces with scroll wheel 69 | 70 | 71 | true 72 | 73 | 74 | 200 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | "normal" 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | "disabled" 91 | 92 | 93 | false 94 | 95 | 96 | false 97 | 98 | 99 | true 100 | 101 | 102 | 103 | 104 | 105 | 106 | 12 107 | 108 | 109 | 4 110 | 111 | 112 | 113 | 'rgba(255,255,255,0.3)' 114 | 115 | 116 | 'rgba(255,255,255,1)' 117 | 118 | 119 | 'rgba(0,0,0,0)' 120 | 121 | 122 | -1 123 | 124 | 125 | 11 126 | 127 | 128 | false 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | "700" 143 | Font weight used for the active workspace 144 | 145 | 146 | 4 147 | 148 | 149 | 0 150 | 151 | 152 | 8 153 | 154 | 155 | 3 156 | 157 | 158 | 159 | 'rgba(0,0,0,0)' 160 | 161 | 162 | 'rgba(255,255,255,1)' 163 | 164 | 165 | 'rgba(0,0,0,0)' 166 | 167 | 168 | false 169 | 170 | 171 | -1 172 | 173 | 174 | false 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | "700" 189 | Font weight used for inactive workspaces 190 | 191 | 192 | false 193 | 194 | 195 | 4 196 | 197 | 198 | 0 199 | 200 | 201 | false 202 | 203 | 204 | false 205 | 206 | 207 | 8 208 | 209 | 210 | false 211 | 212 | 213 | 3 214 | 215 | 216 | false 217 | 218 | 219 | 220 | 'rgba(0,0,0,0)' 221 | 222 | 223 | 'rgba(255,255,255,0.5)' 224 | 225 | 226 | 'rgba(0,0,0,0)' 227 | 228 | 229 | -1 230 | 231 | 232 | false 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | "700" 247 | Font weight used for empty workspaces 248 | 249 | 250 | false 251 | 252 | 253 | 4 254 | 255 | 256 | 0 257 | 258 | 259 | false 260 | 261 | 262 | false 263 | 264 | 265 | 8 266 | 267 | 268 | false 269 | 270 | 271 | 3 272 | 273 | 274 | false 275 | 276 | 277 | '' 278 | 279 | 280 | false 281 | 282 | 283 | false 284 | 285 | 286 | '' 287 | 288 | 289 | 290 | 291 | 292 | true 293 | 294 | 295 | false 296 | 297 | 298 | false 299 | 300 | 301 | 302 | Left']]]> 303 | 304 | 305 | Right']]]> 306 | 307 | 308 | grave']]]> 309 | 310 | Keybinding to activate the previous workspace. 311 | 312 | 313 | n']]]> 314 | Switch to empty workspace. 315 | 316 | 317 | W']]]> 318 | Keybinding to open the workspaces bar menu. 319 | 320 | 321 | 322 | 1']]]> 323 | Keybinding to activate workspace 1. 324 | 325 | 326 | 2']]]> 327 | Keybinding to activate workspace 2. 328 | 329 | 330 | 3']]]> 331 | Keybinding to activate workspace 3. 332 | 333 | 334 | 4']]]> 335 | Keybinding to activate workspace 4. 336 | 337 | 338 | 5']]]> 339 | Keybinding to activate workspace 5. 340 | 341 | 342 | 6']]]> 343 | Keybinding to activate workspace 6. 344 | 345 | 346 | 7']]]> 347 | Keybinding to activate workspace 7. 348 | 349 | 350 | 8']]]> 351 | Keybinding to activate workspace 8. 352 | 353 | 354 | 9']]]> 355 | Keybinding to activate workspace 9. 356 | 357 | 358 | 0']]]> 359 | Keybinding to activate workspace 10. 360 | 361 | 362 | 363 | -------------------------------------------------------------------------------- /src/services/KeyBindings.ts: -------------------------------------------------------------------------------- 1 | import Gio from 'gi://Gio'; 2 | import Meta from 'gi://Meta'; 3 | import Shell from 'gi://Shell'; 4 | import * as Main from 'resource:///org/gnome/shell/ui/main.js'; 5 | import { Settings } from './Settings'; 6 | import { Workspaces } from './Workspaces'; 7 | 8 | export class KeyBindings { 9 | private static _instance: KeyBindings | null; 10 | 11 | static init() { 12 | KeyBindings._instance = new KeyBindings(); 13 | KeyBindings._instance.init(); 14 | } 15 | 16 | static destroy() { 17 | KeyBindings._instance?.destroy(); 18 | KeyBindings._instance = null; 19 | } 20 | 21 | static getInstance(): KeyBindings { 22 | return KeyBindings._instance as KeyBindings; 23 | } 24 | 25 | private readonly _settings = Settings.getInstance(); 26 | private readonly _ws = Workspaces.getInstance(); 27 | private readonly _desktopKeybindings = new Gio.Settings({ 28 | schema: 'org.gnome.desktop.wm.keybindings', 29 | }); 30 | private _addedKeyBindings: string[] = []; 31 | 32 | init() { 33 | this._registerActivateByNumber(); 34 | this._registerMoveToByNumber(); 35 | this._addExtensionKeyBindings(); 36 | KeyBindings._instance = this; 37 | } 38 | 39 | destroy() { 40 | for (const name of this._addedKeyBindings) { 41 | Main.wm.removeKeybinding(name); 42 | } 43 | this._addedKeyBindings = []; 44 | } 45 | 46 | addKeyBinding(name: string, handler: () => void) { 47 | Shell.ActionMode; 48 | Main.wm.addKeybinding( 49 | name, 50 | this._settings.shortcutsSettings, 51 | Meta.KeyBindingFlags.NONE, 52 | Shell.ActionMode.NORMAL | Shell.ActionMode.OVERVIEW, 53 | handler, 54 | ); 55 | this._addedKeyBindings.push(name); 56 | } 57 | 58 | removeKeybinding(name: string) { 59 | if (this._addedKeyBindings.includes(name)) { 60 | Main.wm.removeKeybinding(name); 61 | this._addedKeyBindings.splice(this._addedKeyBindings.indexOf(name), 1); 62 | } 63 | } 64 | 65 | private _addExtensionKeyBindings() { 66 | this.addKeyBinding('move-workspace-left', () => this._ws.moveCurrentWorkspace(-1)); 67 | this.addKeyBinding('move-workspace-right', () => this._ws.moveCurrentWorkspace(1)); 68 | this.addKeyBinding('activate-previous-key', () => this._ws.activatePrevious()); 69 | this.addKeyBinding('activate-empty-key', () => this._ws.activateEmptyOrAdd()); 70 | } 71 | 72 | private _registerActivateByNumber(): void { 73 | this._settings.enableActivateWorkspaceShortcuts.subscribe( 74 | (value) => { 75 | for (let i = 0; i < 10; i++) { 76 | const name = `activate-${i + 1}-key`; 77 | if (value) { 78 | this.addKeyBinding(name, () => { 79 | this._ws.switchTo(i, 'keyboard-shortcut'); 80 | }); 81 | } else { 82 | this.removeKeybinding(name); 83 | } 84 | } 85 | }, 86 | { emitCurrentValue: true }, 87 | ); 88 | } 89 | 90 | private _registerMoveToByNumber(): void { 91 | this._settings.enableMoveToWorkspaceShortcuts.subscribe((value) => { 92 | for (let i = 0; i < 10; i++) { 93 | const name = `move-to-workspace-${i + 1}`; 94 | if (value) { 95 | this._desktopKeybindings.set_strv(name, [`${(i + 1) % 10}`]); 96 | } else { 97 | this._desktopKeybindings.reset(name); 98 | } 99 | } 100 | }); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/services/ScrollHandler.ts: -------------------------------------------------------------------------------- 1 | import Clutter from 'gi://Clutter'; 2 | import * as Main from 'resource:///org/gnome/shell/ui/main.js'; 3 | import { scrollWheelDirectionOptions } from '../preferences/BehaviorPage'; 4 | import { Subject } from '../utils/Subject'; 5 | import { Settings } from './Settings'; 6 | import { Workspaces } from './Workspaces'; 7 | 8 | export class ScrollHandler { 9 | private _ws = Workspaces.getInstance(); 10 | private _settings = Settings.getInstance(); 11 | private _disconnectBinding?: () => void; 12 | private _lastScrollTime = 0; 13 | private _panelButton: any = null; 14 | 15 | init(panelButtonSubject: Subject) { 16 | panelButtonSubject.subscribe((panelButton) => (this._panelButton = panelButton)); 17 | const panelButtonCallback = (panelButton: any) => this._registerScroll(panelButton); 18 | this._settings.scrollWheel.subscribe( 19 | (value) => { 20 | panelButtonSubject.unsubscribe(panelButtonCallback); 21 | this._disconnectBinding?.(); 22 | switch (value) { 23 | case 'panel': 24 | this._registerScroll(Main.panel); 25 | break; 26 | case 'workspaces-bar': 27 | panelButtonSubject.subscribe(panelButtonCallback); 28 | break; 29 | case 'disabled': 30 | this._disconnectBinding = undefined; 31 | break; 32 | } 33 | }, 34 | { emitCurrentValue: true }, 35 | ); 36 | } 37 | 38 | destroy() { 39 | this._disconnectBinding?.(); 40 | this._disconnectBinding = undefined; 41 | } 42 | 43 | private _registerScroll(widget: any): void { 44 | const scrollBinding = widget.connect('scroll-event', (actor: any, event: any) => 45 | this._handle_scroll(actor, event), 46 | ); 47 | this._disconnectBinding = () => widget.disconnect(scrollBinding); 48 | } 49 | 50 | /** 51 | * Checks whether the debounce time since the last scroll event is exceeded, so a scroll event 52 | * can be accepted. 53 | * 54 | * Calling this function resets the debounce timer if the return value is `true`. 55 | * 56 | * @returns `true` if the scroll event should be accepted 57 | */ 58 | private _debounceTimeExceeded(): boolean { 59 | if (!this._settings.scrollWheelDebounce.value) { 60 | return true; 61 | } 62 | const debounceTime = this._settings.scrollWheelDebounceTime.value; 63 | const now = Date.now(); 64 | if (now >= this._lastScrollTime + debounceTime) { 65 | this._lastScrollTime = now; 66 | return true; 67 | } else { 68 | return false; 69 | } 70 | } 71 | 72 | private _handle_scroll(actor: any, event: any): boolean { 73 | // Adapted from https://github.com/timbertson/gnome-shell-scroll-workspaces 74 | let direction: -1 | 1; 75 | let directionSetting: keyof typeof scrollWheelDirectionOptions | null = null; 76 | switch (event.get_scroll_direction()) { 77 | case Clutter.ScrollDirection.UP: 78 | direction = -1; 79 | directionSetting = this._settings.scrollWheelVertical.value; 80 | break; 81 | case Clutter.ScrollDirection.DOWN: 82 | direction = 1; 83 | directionSetting = this._settings.scrollWheelVertical.value; 84 | break; 85 | case Clutter.ScrollDirection.LEFT: 86 | direction = -1; 87 | directionSetting = this._settings.scrollWheelHorizontal.value; 88 | break; 89 | case Clutter.ScrollDirection.RIGHT: 90 | direction = 1; 91 | directionSetting = this._settings.scrollWheelHorizontal.value; 92 | break; 93 | } 94 | let newIndex; 95 | if (directionSetting && directionSetting !== 'disabled') { 96 | const invertFactor = directionSetting === 'inverted' ? -1 : 1; 97 | newIndex = this._ws.findVisibleWorkspace((direction! * invertFactor) as -1 | 1, { 98 | wraparound: this._settings.scrollWheelWrapAround.value, 99 | }); 100 | } else { 101 | return Clutter.EVENT_PROPAGATE; 102 | } 103 | if (newIndex !== null && this._debounceTimeExceeded()) { 104 | const workspace = global.workspace_manager.get_workspace_by_index(newIndex); 105 | if (workspace) { 106 | workspace.activate(global.get_current_time()); 107 | this._ws.focusMostRecentWindowOnWorkspace(workspace); 108 | } 109 | } 110 | return Clutter.EVENT_STOP; 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/services/Settings.ts: -------------------------------------------------------------------------------- 1 | import Gio from 'gi://Gio'; 2 | import { fontWeightOptions } from '../preferences/AppearancePage'; 3 | import { 4 | indicatorStyleOptions, 5 | positionOptions, 6 | scrollWheelDirectionOptions, 7 | scrollWheelOptions, 8 | } from '../preferences/BehaviorPage'; 9 | 10 | export class Settings { 11 | private static _instance: Settings | null; 12 | static init(extension: any) { 13 | Settings._instance = new Settings(extension); 14 | Settings._instance.init(); 15 | } 16 | static destroy() { 17 | Settings._instance?.destroy(); 18 | Settings._instance = null; 19 | } 20 | static getInstance(): Settings { 21 | return Settings._instance as Settings; 22 | } 23 | 24 | constructor(private _extension: any) {} 25 | 26 | readonly state = this._extension.getSettings( 27 | `${this._extension.metadata['settings-schema']}.state`, 28 | ); 29 | readonly behaviorSettings = this._extension.getSettings( 30 | `${this._extension.metadata['settings-schema']}.behavior`, 31 | ); 32 | readonly appearanceSettings = this._extension.getSettings( 33 | `${this._extension.metadata['settings-schema']}.appearance`, 34 | ); 35 | readonly shortcutsSettings = this._extension.getSettings( 36 | `${this._extension.metadata['settings-schema']}.shortcuts`, 37 | ); 38 | readonly mutterSettings = new Gio.Settings({ schema: 'org.gnome.mutter' }); 39 | readonly wmPreferencesSettings = new Gio.Settings({ 40 | schema: 'org.gnome.desktop.wm.preferences', 41 | }); 42 | 43 | private readonly _version = SettingsSubject.createIntSubject(this.state, 'version'); 44 | readonly workspaceNamesMap = SettingsSubject.createJsonObjectSubject<{ 45 | [windowName: string]: string[]; 46 | }>(this.state, 'workspace-names-map'); 47 | readonly dynamicWorkspaces = SettingsSubject.createBooleanSubject( 48 | this.mutterSettings, 49 | 'dynamic-workspaces', 50 | ); 51 | readonly indicatorStyle = SettingsSubject.createStringSubject< 52 | keyof typeof indicatorStyleOptions 53 | >(this.behaviorSettings, 'indicator-style'); 54 | readonly enableCustomLabel = SettingsSubject.createBooleanSubject( 55 | this.behaviorSettings, 56 | 'enable-custom-label', 57 | ); 58 | readonly enableCustomLabelInMenus = SettingsSubject.createBooleanSubject( 59 | this.behaviorSettings, 60 | 'enable-custom-label-in-menu', 61 | ); 62 | readonly customLabelNamed = SettingsSubject.createStringSubject( 63 | this.behaviorSettings, 64 | 'custom-label-named', 65 | ); 66 | readonly customLabelUnnamed = SettingsSubject.createStringSubject( 67 | this.behaviorSettings, 68 | 'custom-label-unnamed', 69 | ); 70 | readonly position = SettingsSubject.createStringSubject( 71 | this.behaviorSettings, 72 | 'position', 73 | ); 74 | readonly systemWorkspaceIndicator = SettingsSubject.createBooleanSubject( 75 | this.behaviorSettings, 76 | 'system-workspace-indicator', 77 | ); 78 | readonly positionIndex = SettingsSubject.createIntSubject( 79 | this.behaviorSettings, 80 | 'position-index', 81 | ); 82 | readonly scrollWheel = SettingsSubject.createStringSubject( 83 | this.behaviorSettings, 84 | 'scroll-wheel', 85 | ); 86 | readonly scrollWheelDebounce = SettingsSubject.createBooleanSubject( 87 | this.behaviorSettings, 88 | 'scroll-wheel-debounce', 89 | ); 90 | readonly scrollWheelDebounceTime = SettingsSubject.createIntSubject( 91 | this.behaviorSettings, 92 | 'scroll-wheel-debounce-time', 93 | ); 94 | readonly scrollWheelVertical = SettingsSubject.createStringSubject< 95 | keyof typeof scrollWheelDirectionOptions 96 | >(this.behaviorSettings, 'scroll-wheel-vertical'); 97 | readonly scrollWheelHorizontal = SettingsSubject.createStringSubject< 98 | keyof typeof scrollWheelDirectionOptions 99 | >(this.behaviorSettings, 'scroll-wheel-horizontal'); 100 | readonly scrollWheelWrapAround = SettingsSubject.createBooleanSubject( 101 | this.behaviorSettings, 102 | 'scroll-wheel-wrap-around', 103 | ); 104 | readonly alwaysShowNumbers = SettingsSubject.createBooleanSubject( 105 | this.behaviorSettings, 106 | 'always-show-numbers', 107 | ); 108 | readonly showEmptyWorkspaces = SettingsSubject.createBooleanSubject( 109 | this.behaviorSettings, 110 | 'show-empty-workspaces', 111 | ); 112 | readonly toggleOverview = SettingsSubject.createBooleanSubject( 113 | this.behaviorSettings, 114 | 'toggle-overview', 115 | ); 116 | readonly smartWorkspaceNames = SettingsSubject.createBooleanSubject( 117 | this.behaviorSettings, 118 | 'smart-workspace-names', 119 | ); 120 | readonly reevaluateSmartWorkspaceNames = SettingsSubject.createBooleanSubject( 121 | this.behaviorSettings, 122 | 'reevaluate-smart-workspace-names', 123 | ); 124 | readonly enableActivateWorkspaceShortcuts = SettingsSubject.createBooleanSubject( 125 | this.shortcutsSettings, 126 | 'enable-activate-workspace-shortcuts', 127 | ); 128 | readonly backAndForth = SettingsSubject.createBooleanSubject( 129 | this.shortcutsSettings, 130 | 'back-and-forth', 131 | ); 132 | readonly enableMoveToWorkspaceShortcuts = SettingsSubject.createBooleanSubject( 133 | this.shortcutsSettings, 134 | 'enable-move-to-workspace-shortcuts', 135 | ); 136 | readonly workspaceNames = SettingsSubject.createStringArraySubject( 137 | this.wmPreferencesSettings, 138 | 'workspace-names', 139 | ); 140 | readonly workspacesBarPadding = SettingsSubject.createIntSubject( 141 | this.appearanceSettings, 142 | 'workspaces-bar-padding', 143 | ); 144 | readonly workspaceMargin = SettingsSubject.createIntSubject( 145 | this.appearanceSettings, 146 | 'workspace-margin', 147 | ); 148 | readonly activeWorkspaceBackgroundColor = SettingsSubject.createStringSubject( 149 | this.appearanceSettings, 150 | 'active-workspace-background-color', 151 | ); 152 | readonly activeWorkspaceTextColor = SettingsSubject.createStringSubject( 153 | this.appearanceSettings, 154 | 'active-workspace-text-color', 155 | ); 156 | readonly activeWorkspaceBorderColor = SettingsSubject.createStringSubject( 157 | this.appearanceSettings, 158 | 'active-workspace-border-color', 159 | ); 160 | readonly activeWorkspaceFontSize = SettingsSubject.createIntSubject( 161 | this.appearanceSettings, 162 | 'active-workspace-font-size', 163 | ); 164 | readonly activeWorkspaceFontWeight = SettingsSubject.createStringSubject< 165 | keyof typeof fontWeightOptions 166 | >(this.appearanceSettings, 'active-workspace-font-weight'); 167 | readonly activeWorkspaceBorderRadius = SettingsSubject.createIntSubject( 168 | this.appearanceSettings, 169 | 'active-workspace-border-radius', 170 | ); 171 | readonly activeWorkspaceBorderWidth = SettingsSubject.createIntSubject( 172 | this.appearanceSettings, 173 | 'active-workspace-border-width', 174 | ); 175 | readonly activeWorkspacePaddingH = SettingsSubject.createIntSubject( 176 | this.appearanceSettings, 177 | 'active-workspace-padding-h', 178 | ); 179 | readonly activeWorkspacePaddingV = SettingsSubject.createIntSubject( 180 | this.appearanceSettings, 181 | 'active-workspace-padding-v', 182 | ); 183 | readonly inactiveWorkspaceBackgroundColor = SettingsSubject.createStringSubject( 184 | this.appearanceSettings, 185 | 'inactive-workspace-background-color', 186 | ); 187 | readonly inactiveWorkspaceTextColor = SettingsSubject.createStringSubject( 188 | this.appearanceSettings, 189 | 'inactive-workspace-text-color', 190 | ); 191 | readonly inactiveWorkspaceBorderColor = SettingsSubject.createStringSubject( 192 | this.appearanceSettings, 193 | 'inactive-workspace-border-color', 194 | ); 195 | readonly inactiveWorkspaceFontSize = SettingsSubject.createIntSubject( 196 | this.appearanceSettings, 197 | 'inactive-workspace-font-size', 198 | ); 199 | readonly inactiveWorkspaceFontWeight = SettingsSubject.createStringSubject< 200 | keyof typeof fontWeightOptions 201 | >(this.appearanceSettings, 'inactive-workspace-font-weight'); 202 | readonly inactiveWorkspaceBorderRadius = SettingsSubject.createIntSubject( 203 | this.appearanceSettings, 204 | 'inactive-workspace-border-radius', 205 | ); 206 | readonly inactiveWorkspaceBorderWidth = SettingsSubject.createIntSubject( 207 | this.appearanceSettings, 208 | 'inactive-workspace-border-width', 209 | ); 210 | readonly inactiveWorkspacePaddingH = SettingsSubject.createIntSubject( 211 | this.appearanceSettings, 212 | 'inactive-workspace-padding-h', 213 | ); 214 | readonly inactiveWorkspacePaddingV = SettingsSubject.createIntSubject( 215 | this.appearanceSettings, 216 | 'inactive-workspace-padding-v', 217 | ); 218 | readonly emptyWorkspaceBackgroundColor = SettingsSubject.createStringSubject( 219 | this.appearanceSettings, 220 | 'empty-workspace-background-color', 221 | ); 222 | readonly emptyWorkspaceTextColor = SettingsSubject.createStringSubject( 223 | this.appearanceSettings, 224 | 'empty-workspace-text-color', 225 | ); 226 | readonly emptyWorkspaceBorderColor = SettingsSubject.createStringSubject( 227 | this.appearanceSettings, 228 | 'empty-workspace-border-color', 229 | ); 230 | readonly emptyWorkspaceFontSize = SettingsSubject.createIntSubject( 231 | this.appearanceSettings, 232 | 'empty-workspace-font-size', 233 | ); 234 | readonly emptyWorkspaceFontWeight = SettingsSubject.createStringSubject< 235 | keyof typeof fontWeightOptions 236 | >(this.appearanceSettings, 'empty-workspace-font-weight'); 237 | readonly emptyWorkspaceBorderRadius = SettingsSubject.createIntSubject( 238 | this.appearanceSettings, 239 | 'empty-workspace-border-radius', 240 | ); 241 | readonly emptyWorkspaceBorderWidth = SettingsSubject.createIntSubject( 242 | this.appearanceSettings, 243 | 'empty-workspace-border-width', 244 | ); 245 | readonly emptyWorkspacePaddingH = SettingsSubject.createIntSubject( 246 | this.appearanceSettings, 247 | 'empty-workspace-padding-h', 248 | ); 249 | readonly emptyWorkspacePaddingV = SettingsSubject.createIntSubject( 250 | this.appearanceSettings, 251 | 'empty-workspace-padding-v', 252 | ); 253 | readonly applicationStyles = SettingsSubject.createStringSubject( 254 | this.appearanceSettings, 255 | 'application-styles', 256 | ); 257 | readonly customStylesEnabled = SettingsSubject.createBooleanSubject( 258 | this.appearanceSettings, 259 | 'custom-styles-enabled', 260 | ); 261 | readonly customStylesFailed = SettingsSubject.createBooleanSubject( 262 | this.appearanceSettings, 263 | 'custom-styles-failed', 264 | ); 265 | readonly customStyles = SettingsSubject.createStringSubject( 266 | this.appearanceSettings, 267 | 'custom-styles', 268 | ); 269 | 270 | private init() { 271 | SettingsSubject.initAll(); 272 | this.runMigrations(); 273 | } 274 | 275 | private destroy() { 276 | SettingsSubject.destroyAll(); 277 | } 278 | 279 | /** 280 | * Migrates preferences from previous space-bar versions. 281 | */ 282 | private runMigrations(): void { 283 | if (this._version.value < 26) { 284 | if ((this.indicatorStyle.value as string) === 'current-workspace-name') { 285 | this.indicatorStyle.value = 'current-workspace'; 286 | } 287 | } 288 | this._version.value = this._extension.metadata['version']; 289 | } 290 | } 291 | 292 | class SettingsSubject { 293 | private static _subjects: SettingsSubject[] = []; 294 | static createBooleanSubject(settings: Gio.Settings, name: string): SettingsSubject { 295 | return new SettingsSubject(settings, name, 'boolean'); 296 | } 297 | static createIntSubject(settings: Gio.Settings, name: string): SettingsSubject { 298 | return new SettingsSubject(settings, name, 'int'); 299 | } 300 | static createStringSubject( 301 | settings: Gio.Settings, 302 | name: string, 303 | ): SettingsSubject { 304 | return new SettingsSubject(settings, name, 'string'); 305 | } 306 | static createStringArraySubject( 307 | settings: Gio.Settings, 308 | name: string, 309 | ): SettingsSubject { 310 | return new SettingsSubject(settings, name, 'string-array'); 311 | } 312 | static createJsonObjectSubject(settings: Gio.Settings, name: string): SettingsSubject { 313 | return new SettingsSubject(settings, name, 'json-object'); 314 | } 315 | static initAll() { 316 | for (const subject of SettingsSubject._subjects) { 317 | subject._init(); 318 | } 319 | } 320 | static destroyAll() { 321 | for (const subject of SettingsSubject._subjects) { 322 | subject._destroy(); 323 | } 324 | SettingsSubject._subjects = []; 325 | } 326 | 327 | get value() { 328 | return this._value; 329 | } 330 | set value(value: T) { 331 | this._setValue(value); 332 | } 333 | 334 | private _value!: T; 335 | private _subscribers: ((value: T) => void)[] = []; 336 | private _getValue!: () => T; 337 | private _setValue!: (value: T) => void; 338 | private _disconnect!: () => void; 339 | 340 | private constructor( 341 | private readonly _settings: Gio.Settings, 342 | private readonly _name: string, 343 | private readonly _type: 'boolean' | 'int' | 'string' | 'string-array' | 'json-object', 344 | ) { 345 | SettingsSubject._subjects.push(this); 346 | } 347 | 348 | subscribe(subscriber: (value: T) => void, { emitCurrentValue = false } = {}) { 349 | this._subscribers.push(subscriber); 350 | if (emitCurrentValue) { 351 | subscriber(this._value); 352 | } 353 | } 354 | 355 | private _init(): void { 356 | this._getValue = () => { 357 | switch (this._type) { 358 | case 'boolean': 359 | return this._settings.get_boolean(this._name) as unknown as T; 360 | case 'int': 361 | return this._settings.get_int(this._name) as unknown as T; 362 | case 'string': 363 | return this._settings.get_string(this._name) as unknown as T; 364 | case 'string-array': 365 | return this._settings.get_strv(this._name) as unknown as T; 366 | case 'json-object': 367 | return JSON.parse(this._settings.get_string(this._name)!) as unknown as T; 368 | default: 369 | throw new Error('unknown type ' + this._type); 370 | } 371 | }; 372 | this._setValue = (value: T) => { 373 | switch (this._type) { 374 | case 'boolean': 375 | return this._settings.set_boolean(this._name, value as unknown as boolean); 376 | case 'int': 377 | return this._settings.set_int(this._name, value as unknown as number); 378 | case 'string': 379 | return this._settings.set_string(this._name, value as unknown as string); 380 | case 'string-array': 381 | return this._settings.set_strv(this._name, value as unknown as string[]); 382 | case 'json-object': 383 | return this._settings.set_string(this._name, JSON.stringify(value)); 384 | default: 385 | throw new Error('unknown type ' + this._type); 386 | } 387 | }; 388 | this._value = this._getValue(); 389 | const changed = this._settings.connect(`changed::${this._name}`, () => 390 | this._updateValue(this._getValue()), 391 | ); 392 | this._disconnect = () => this._settings.disconnect(changed); 393 | } 394 | 395 | private _destroy(): void { 396 | this._disconnect(); 397 | this._subscribers = []; 398 | } 399 | 400 | private _updateValue(value: T) { 401 | this._value = value; 402 | this._notifySubscriber(); 403 | } 404 | 405 | private _notifySubscriber(): void { 406 | for (const subscriber of this._subscribers) { 407 | subscriber(this._value); 408 | } 409 | } 410 | } 411 | -------------------------------------------------------------------------------- /src/services/Styles.ts: -------------------------------------------------------------------------------- 1 | import Gio from 'gi://Gio'; 2 | import { DebouncingNotifier } from '../utils/DebouncingNotifier'; 3 | import { Settings } from './Settings'; 4 | import St from 'gi://St'; 5 | 6 | /** 7 | * Tracks and provides the styles for the workspaces bar. 8 | */ 9 | export class Styles { 10 | static _instance: Styles | null; 11 | 12 | static init() { 13 | Styles._instance = new Styles(); 14 | Styles._instance.init(); 15 | } 16 | 17 | static destroy() { 18 | Styles._instance!.destroy(); 19 | Styles._instance = null; 20 | } 21 | 22 | static getInstance(): Styles { 23 | return Styles._instance as Styles; 24 | } 25 | 26 | private readonly _settings = Settings.getInstance(); 27 | 28 | /** Notifier for changed styles of the workspaces bar. */ 29 | private readonly _workspacesBarUpdateNotifier = new DebouncingNotifier(); 30 | /** Notifier for changed styles of workspaces labels. */ 31 | private readonly _workspaceUpdateNotifier = new DebouncingNotifier(); 32 | /** 33 | * Temporary file containing dynamically loaded styles. 34 | * 35 | * We delete the file right after usage, but we keep a reference so we can 36 | * unload the styles later. 37 | */ 38 | private _dynamicStyleSheet?: Gio.File; 39 | 40 | init() { 41 | this._registerSettingChanges(); 42 | this._updateStyleSheet(); 43 | } 44 | 45 | destroy() { 46 | this._workspaceUpdateNotifier.destroy(); 47 | this._unloadStyleSheet(); 48 | } 49 | 50 | private _updateStyleSheet(): void { 51 | this._unloadStyleSheet(); 52 | const themeContext = St.ThemeContext.get_for_stage(global.stage); 53 | let styles = this._generateStyleSheetContent(); 54 | this._settings.applicationStyles.value = styles; 55 | if (this._settings.customStylesEnabled.value) { 56 | this._settings.customStylesFailed.value = false; 57 | styles = styles + '\n' + this._settings.customStyles.value; 58 | } 59 | const [file, stream] = Gio.File.new_tmp(null); 60 | const outputStream = Gio.DataOutputStream.new(stream.outputStream); 61 | outputStream.put_string(styles, null); 62 | try { 63 | themeContext.get_theme().load_stylesheet(file); 64 | } catch (e) { 65 | console.error('Failed to load stylesheet'); 66 | if (this._settings.customStylesEnabled.value) { 67 | this._settings.customStylesEnabled.value = false; 68 | this._settings.customStylesFailed.value = true; 69 | } 70 | } 71 | outputStream.close(null); 72 | stream.close(null); 73 | this._dynamicStyleSheet = file; 74 | } 75 | 76 | private _unloadStyleSheet(): void { 77 | if (this._dynamicStyleSheet) { 78 | const themeContext = St.ThemeContext.get_for_stage(global.stage); 79 | themeContext.get_theme().unload_stylesheet(this._dynamicStyleSheet); 80 | this._dynamicStyleSheet.delete(null); 81 | this._dynamicStyleSheet = undefined; 82 | } 83 | } 84 | 85 | private _generateStyleSheetContent(): string { 86 | let content = `.space-bar {\n${this._getWorkspacesBarStyle()}}\n\n`; 87 | content += `.space-bar-workspace-label.active {\n${this._getActiveWorkspaceStyle()}}\n\n`; 88 | content += `.space-bar-workspace-label.inactive {\n${this._getInactiveWorkspaceStyle()}}\n\n`; 89 | content += `.space-bar-workspace-label.inactive.empty {\n${this._getEmptyWorkspaceStyle()}}`; 90 | return content; 91 | } 92 | 93 | /** Calls `callback` when the style of the workspaces bar changed. */ 94 | onWorkspacesBarChanged(callback: () => void): void { 95 | this._workspacesBarUpdateNotifier.subscribe(callback); 96 | } 97 | 98 | /** Calls `callback` when the style of a workspaces label changed. */ 99 | onWorkspaceLabelsChanged(callback: () => void): void { 100 | this._workspaceUpdateNotifier.subscribe(callback); 101 | } 102 | 103 | /** Subscribes to settings and updates changed styles. */ 104 | private _registerSettingChanges(): void { 105 | [this._settings.workspacesBarPadding].forEach((setting) => 106 | setting.subscribe(() => { 107 | this._updateStyleSheet(); 108 | this._workspacesBarUpdateNotifier.notify(); 109 | }), 110 | ); 111 | [ 112 | this._settings.workspaceMargin, 113 | this._settings.activeWorkspaceBackgroundColor, 114 | this._settings.activeWorkspaceTextColor, 115 | this._settings.activeWorkspaceBorderColor, 116 | this._settings.activeWorkspaceFontSize, 117 | this._settings.activeWorkspaceFontWeight, 118 | this._settings.activeWorkspaceBorderRadius, 119 | this._settings.activeWorkspaceBorderWidth, 120 | this._settings.activeWorkspacePaddingH, 121 | this._settings.activeWorkspacePaddingV, 122 | ].forEach((setting) => 123 | setting.subscribe(() => { 124 | this._updateStyleSheet(); 125 | this._workspaceUpdateNotifier.notify(); 126 | }), 127 | ); 128 | [ 129 | this._settings.workspaceMargin, 130 | this._settings.inactiveWorkspaceBackgroundColor, 131 | this._settings.inactiveWorkspaceTextColor, 132 | this._settings.inactiveWorkspaceBorderColor, 133 | this._settings.inactiveWorkspaceFontSize, 134 | this._settings.inactiveWorkspaceFontWeight, 135 | this._settings.inactiveWorkspaceBorderRadius, 136 | this._settings.inactiveWorkspaceBorderWidth, 137 | this._settings.inactiveWorkspacePaddingH, 138 | this._settings.inactiveWorkspacePaddingV, 139 | ].forEach((setting) => 140 | setting.subscribe(() => { 141 | this._updateStyleSheet(); 142 | this._workspaceUpdateNotifier.notify(); 143 | }), 144 | ); 145 | [ 146 | this._settings.workspaceMargin, 147 | this._settings.emptyWorkspaceBackgroundColor, 148 | this._settings.emptyWorkspaceTextColor, 149 | this._settings.emptyWorkspaceBorderColor, 150 | this._settings.emptyWorkspaceFontSize, 151 | this._settings.emptyWorkspaceFontWeight, 152 | this._settings.emptyWorkspaceBorderRadius, 153 | this._settings.emptyWorkspaceBorderWidth, 154 | this._settings.emptyWorkspacePaddingH, 155 | this._settings.emptyWorkspacePaddingV, 156 | ].forEach((setting) => 157 | setting.subscribe(() => { 158 | this._updateStyleSheet(); 159 | this._workspaceUpdateNotifier.notify(); 160 | }), 161 | ); 162 | this._settings.customStylesEnabled.subscribe(() => { 163 | this._updateStyleSheet(); 164 | this._workspacesBarUpdateNotifier.notify(); 165 | }); 166 | this._settings.customStyles.subscribe(() => { 167 | if (this._settings.customStylesEnabled.value) { 168 | this._updateStyleSheet(); 169 | this._workspacesBarUpdateNotifier.notify(); 170 | } 171 | }); 172 | } 173 | 174 | /** Updated style the workspaces-bar panel button. */ 175 | private _getWorkspacesBarStyle(): string { 176 | const padding = this._settings.workspacesBarPadding.value; 177 | let workspacesBarStyle = ` -natural-hpadding: ${padding}px;\n`; 178 | return workspacesBarStyle; 179 | } 180 | 181 | /** Updated style for active workspaces labels. */ 182 | private _getActiveWorkspaceStyle(): string { 183 | const margin = this._settings.workspaceMargin.value; 184 | const backgroundColor = this._settings.activeWorkspaceBackgroundColor.value; 185 | const textColor = this._settings.activeWorkspaceTextColor.value; 186 | const borderColor = this._settings.activeWorkspaceBorderColor.value; 187 | const fontSize = this._settings.activeWorkspaceFontSize.value; 188 | const fontWeight = this._settings.activeWorkspaceFontWeight.value; 189 | const borderRadius = this._settings.activeWorkspaceBorderRadius.value; 190 | const borderWidth = this._settings.activeWorkspaceBorderWidth.value; 191 | const paddingH = this._settings.activeWorkspacePaddingH.value; 192 | const paddingV = this._settings.activeWorkspacePaddingV.value; 193 | let activeWorkspaceStyle = 194 | ` margin: 0 ${margin}px;\n` + 195 | ` background-color: ${backgroundColor};\n` + 196 | ` color: ${textColor};\n` + 197 | ` border-color: ${borderColor};\n` + 198 | ` font-weight: ${fontWeight};\n` + 199 | ` border-radius: ${borderRadius}px;\n` + 200 | ` border-width: ${borderWidth}px;\n` + 201 | ` padding: ${paddingV}px ${paddingH}px;\n`; 202 | if (fontSize >= 0) { 203 | activeWorkspaceStyle += ` font-size: ${fontSize}pt;\n`; 204 | } 205 | return activeWorkspaceStyle; 206 | } 207 | 208 | /** Updated style for inactive workspaces labels. */ 209 | private _getInactiveWorkspaceStyle(): string { 210 | const margin = this._settings.workspaceMargin.value; 211 | const backgroundColor = this._settings.inactiveWorkspaceBackgroundColor.value; 212 | const textColor = this._settings.inactiveWorkspaceTextColor.value; 213 | const borderColor = this._settings.inactiveWorkspaceBorderColor.value; 214 | const fontSize = this._settings.inactiveWorkspaceFontSize.value; 215 | const fontWeight = this._settings.inactiveWorkspaceFontWeight.value; 216 | const borderRadius = this._settings.inactiveWorkspaceBorderRadius.value; 217 | const borderWidth = this._settings.inactiveWorkspaceBorderWidth.value; 218 | const paddingH = this._settings.inactiveWorkspacePaddingH.value; 219 | const paddingV = this._settings.inactiveWorkspacePaddingV.value; 220 | let inactiveWorkspaceStyle = 221 | ` margin: 0 ${margin}px;\n` + 222 | ` background-color: ${backgroundColor};\n` + 223 | ` color: ${textColor};\n` + 224 | ` border-color: ${borderColor};\n` + 225 | ` font-weight: ${fontWeight};\n` + 226 | ` border-radius: ${borderRadius}px;\n` + 227 | ` border-width: ${borderWidth}px;\n` + 228 | ` padding: ${paddingV}px ${paddingH}px;\n`; 229 | if (fontSize >= 0) { 230 | inactiveWorkspaceStyle += ` font-size: ${fontSize}pt;\n`; 231 | } 232 | return inactiveWorkspaceStyle; 233 | } 234 | 235 | /** Updated style for empty and inactive workspaces labels. */ 236 | private _getEmptyWorkspaceStyle(): string { 237 | const margin = this._settings.workspaceMargin.value; 238 | const backgroundColor = this._settings.emptyWorkspaceBackgroundColor.value; 239 | const textColor = this._settings.emptyWorkspaceTextColor.value; 240 | const borderColor = this._settings.emptyWorkspaceBorderColor.value; 241 | const fontSize = this._settings.emptyWorkspaceFontSize.value; 242 | const fontWeight = this._settings.emptyWorkspaceFontWeight.value; 243 | const borderRadius = this._settings.emptyWorkspaceBorderRadius.value; 244 | const borderWidth = this._settings.emptyWorkspaceBorderWidth.value; 245 | const paddingH = this._settings.emptyWorkspacePaddingH.value; 246 | const paddingV = this._settings.emptyWorkspacePaddingV.value; 247 | let emptyWorkspaceStyle = 248 | ` margin: 0 ${margin}px;\n` + 249 | ` background-color: ${backgroundColor};\n` + 250 | ` color: ${textColor};\n` + 251 | ` border-color: ${borderColor};\n` + 252 | ` font-weight: ${fontWeight};\n` + 253 | ` border-radius: ${borderRadius}px;\n` + 254 | ` border-width: ${borderWidth}px;\n` + 255 | ` padding: ${paddingV}px ${paddingH}px;\n`; 256 | if (fontSize >= 0) { 257 | emptyWorkspaceStyle += ` font-size: ${fontSize}pt;\n`; 258 | } 259 | return emptyWorkspaceStyle; 260 | } 261 | } 262 | -------------------------------------------------------------------------------- /src/services/TopBarAdjustments.ts: -------------------------------------------------------------------------------- 1 | import * as Main from 'resource:///org/gnome/shell/ui/main.js'; 2 | import { Settings } from './Settings'; 3 | 4 | export class TopBarAdjustments { 5 | private static _instance: TopBarAdjustments | null; 6 | 7 | static init() { 8 | TopBarAdjustments._instance = new TopBarAdjustments(); 9 | TopBarAdjustments._instance.init(); 10 | } 11 | 12 | static destroy() { 13 | TopBarAdjustments._instance!.destroy(); 14 | TopBarAdjustments._instance = null; 15 | } 16 | 17 | private readonly _settings = Settings.getInstance(); 18 | private _didHideActivitiesButton = false; 19 | 20 | init(): void { 21 | this._settings.systemWorkspaceIndicator.subscribe( 22 | (systemWorkspaceIndicator) => { 23 | if (systemWorkspaceIndicator) { 24 | this._restoreSystemWorkspaceIndicator(); 25 | } else { 26 | this._hideSystemWorkspaceIndicator(); 27 | } 28 | }, 29 | { emitCurrentValue: true }, 30 | ); 31 | } 32 | 33 | destroy(): void { 34 | this._restoreSystemWorkspaceIndicator(); 35 | } 36 | 37 | private _hideSystemWorkspaceIndicator(): void { 38 | const activitiesButton = Main.panel.statusArea['activities']; 39 | if (activitiesButton && !Main.sessionMode.isLocked && activitiesButton.is_visible()) { 40 | activitiesButton.hide(); 41 | this._didHideActivitiesButton = true; 42 | } 43 | } 44 | 45 | private _restoreSystemWorkspaceIndicator(): void { 46 | const activitiesButton = Main.panel.statusArea['activities']; 47 | if (activitiesButton && this._didHideActivitiesButton) { 48 | activitiesButton.show(); 49 | this._didHideActivitiesButton = false; 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/services/WorkspaceNames.ts: -------------------------------------------------------------------------------- 1 | import type Meta from 'gi://Meta'; 2 | import { Settings } from './Settings'; 3 | import type { WorkspaceState, Workspaces } from './Workspaces'; 4 | type Window = Meta.Window; 5 | 6 | export class WorkspaceNames { 7 | private static _instance: WorkspaceNames | null; 8 | static init(workspaces: Workspaces): WorkspaceNames { 9 | this._instance = new WorkspaceNames(workspaces); 10 | return this._instance; 11 | } 12 | static getInstance(): WorkspaceNames { 13 | return this._instance as WorkspaceNames; 14 | } 15 | 16 | private readonly _settings = Settings.getInstance(); 17 | 18 | private constructor(private readonly _ws: Workspaces) {} 19 | 20 | insert(index: number): void { 21 | const workspaceNames = this._getNames(); 22 | if (index < workspaceNames.length) { 23 | workspaceNames.splice(index, 0, ''); 24 | } else { 25 | setArrayValue(workspaceNames, index, ''); 26 | } 27 | this._setNames(workspaceNames); 28 | } 29 | 30 | /** 31 | * Reorders workspace names according to the given map. 32 | * 33 | * Has the possibility to insert and to remove workspaces. 34 | * 35 | * @param reorderMap array where keys are new indexes and values are old indexes of workspaces 36 | */ 37 | reorder(reorderMap: number[]): void { 38 | const oldWorkspaceNames = this._getNames(); 39 | const newWorkspaceNames: string[] = []; 40 | for (const [newIndex, oldIndex] of reorderMap.entries()) { 41 | if (oldIndex >= 0) { 42 | newWorkspaceNames[newIndex] = oldWorkspaceNames[oldIndex] ?? ''; 43 | } else { 44 | newWorkspaceNames[newIndex] = ''; 45 | } 46 | } 47 | this._setNames(newWorkspaceNames); 48 | } 49 | 50 | remove(index: number): void { 51 | const workspaceNames = this._getNames(); 52 | workspaceNames.splice(index, 1); 53 | this._setNames(workspaceNames); 54 | } 55 | 56 | rename(index: number, newName: string): void { 57 | let workspaceNames = this._getNames(); 58 | setArrayValue(workspaceNames, index, newName); 59 | this._setNames(workspaceNames); 60 | if (this._settings.smartWorkspaceNames.value && newName) { 61 | this._saveSmartWorkspaceName(index, newName); 62 | } 63 | } 64 | 65 | restoreSmartWorkspaceName(index: number) { 66 | const windowNames = this._getWindowNames(index); 67 | const workspacesNamesMap = this._settings.workspaceNamesMap.value; 68 | // Loop through windows on the workspace. 69 | for (const windowName of windowNames) { 70 | // Find the first associated name that is not already in use. 71 | if (workspacesNamesMap[windowName]?.length > 0) { 72 | const newName = workspacesNamesMap[windowName].find( 73 | (name) => !this._getEnabledWorkspaceNames().includes(name), 74 | ); 75 | if (newName) { 76 | let workspaceNames = this._getNames(); 77 | while (workspaceNames.length < index) { 78 | workspaceNames.push(''); 79 | } 80 | workspaceNames[index] = newName; 81 | this._setNames(workspaceNames); 82 | return; 83 | } 84 | } 85 | } 86 | } 87 | 88 | workspaceNameIsSupportedByWindows(workspace: WorkspaceState): boolean { 89 | const windowNames = this._getWindowNames(workspace.index); 90 | const workspacesNamesMap = this._settings.workspaceNamesMap.value; 91 | for (const windowName of windowNames) { 92 | if (workspacesNamesMap[windowName]?.some((name) => name === workspace.name)) { 93 | return true; 94 | } 95 | } 96 | return false; 97 | } 98 | 99 | /** 100 | * Associates windows on a workspace with a new workspace name. 101 | */ 102 | private _saveSmartWorkspaceName(index: number, newName: string) { 103 | const windowNames = this._getWindowNames(index); 104 | const workspacesNamesMap = this._settings.workspaceNamesMap.value; 105 | for (const windowName of windowNames) { 106 | workspacesNamesMap[windowName] = [ 107 | ...(workspacesNamesMap[windowName] ?? []).filter( 108 | (name) => 109 | // We add `newName` at the end 110 | name !== newName && 111 | // Keep associations with other currently enabled workspaces and drop others 112 | this._getEnabledWorkspaceNames().includes(name), 113 | ), 114 | newName, 115 | ]; 116 | } 117 | this._settings.workspaceNamesMap.value = workspacesNamesMap; 118 | } 119 | 120 | private _getWindowNames(workspaceIndex: number): string[] { 121 | const workspace = global.workspace_manager.get_workspace_by_index(workspaceIndex); 122 | let windows: Window[] = workspace!.list_windows(); 123 | windows = windows.filter((window) => !window.is_on_all_workspaces()); 124 | return windows 125 | .map((window) => window.get_wm_class()) 126 | .filter((wmClass): wmClass is string => wmClass !== null); 127 | } 128 | 129 | private _getNames(): string[] { 130 | return [...this._settings.workspaceNames.value]; 131 | } 132 | 133 | private _setNames(names: string[]): void { 134 | while (names[names.length - 1] === '') { 135 | names.pop(); 136 | } 137 | this._settings.workspaceNames.value = names; 138 | } 139 | 140 | private _getEnabledWorkspaceNames(): string[] { 141 | return this._getNames().filter((_, index) => index < this._ws.numberOfEnabledWorkspaces); 142 | } 143 | } 144 | 145 | /** 146 | * Sets the array's value at the given index, padding any missing elements so all elements have 147 | * valid values. 148 | */ 149 | function setArrayValue(array: string[], index: number, value: string): void { 150 | while (array.length < index) { 151 | array.push(''); 152 | } 153 | array[index] = value; 154 | } 155 | -------------------------------------------------------------------------------- /src/services/Workspaces.ts: -------------------------------------------------------------------------------- 1 | import Meta from 'gi://Meta'; 2 | import Shell from 'gi://Shell'; 3 | import * as Main from 'resource:///org/gnome/shell/ui/main.js'; 4 | import { WindowManager } from 'resource:///org/gnome/shell/ui/windowManager.js'; 5 | import { DebouncingNotifier } from '../utils/DebouncingNotifier'; 6 | import { Subject } from '../utils/Subject'; 7 | import { hook } from '../utils/hook'; 8 | import { Settings } from './Settings'; 9 | import { WorkspaceNames } from './WorkspaceNames'; 10 | 11 | // Adapted from https://gitlab.gnome.org/GNOME/gnome-shell/-/blob/45.0/js/ui/altTab.js?ref_type=tags#L53 12 | function getWindows(workspace: Meta.Workspace): Meta.Window[] { 13 | const windows = global.display.get_tab_list(Meta.TabList.NORMAL_ALL, workspace); 14 | return windows 15 | .map((w) => (w.is_attached_dialog() ? w.get_transient_for()! : w)) 16 | .filter((w, i, a) => !w.skipTaskbar && a.indexOf(w) === i); 17 | } 18 | 19 | export interface WorkspaceState { 20 | isEnabled: boolean; 21 | /** Whether the workspace is currently shown in the workspaces bar. */ 22 | isVisible: boolean; 23 | index: number; 24 | name?: string | null; 25 | hasWindows: boolean; 26 | } 27 | 28 | export type UpdateReason = 29 | | 'init' 30 | | 'active-workspace-changed' 31 | | 'workspaces-changed' 32 | | 'workspace-names-changed' 33 | | 'windows-changed'; 34 | 35 | type Workspace = any; 36 | type Window = any; 37 | 38 | export class Workspaces { 39 | static _instance: Workspaces | null; 40 | 41 | static init() { 42 | Workspaces._instance = new Workspaces(); 43 | Workspaces._instance.init(); 44 | } 45 | 46 | static destroy() { 47 | Workspaces._instance!.destroy(); 48 | Workspaces._instance = null; 49 | } 50 | 51 | static getInstance(): Workspaces { 52 | return Workspaces._instance as Workspaces; 53 | } 54 | 55 | numberOfEnabledWorkspaces = 0; 56 | lastVisibleWorkspace = 0; 57 | currentIndex = 0; 58 | workspaces: WorkspaceState[] = []; 59 | 60 | private _previousWorkspace = 0; 61 | private _metaWorkspaces: Meta.Workspace[] = []; 62 | private _ws_changed?: number; 63 | private _ws_reordered?: number; 64 | private _ws_active_changed?: number; 65 | private _windows_changed?: number; 66 | private _settings = Settings.getInstance(); 67 | private _wsNames?: WorkspaceNames | null; 68 | private _updateNotifier = new DebouncingNotifier(); 69 | private _smartNamesNotifier = new DebouncingNotifier(); 70 | /** 71 | * Listeners for windows being added to a workspace. 72 | * 73 | * The listeners are connected to a workspace and there is one listener per workspace that needs 74 | * tracking. 75 | */ 76 | private _windowChangedListeners: { workspace: Meta.Workspace; listener: number }[] = []; 77 | 78 | init() { 79 | this._wsNames = WorkspaceNames.init(this); 80 | this._ws_reordered = global.workspace_manager.connect('workspaces-reordered', () => { 81 | this._update('workspaces-changed', 'workspace_manager workspaces-reordered'); 82 | }); 83 | this._ws_changed = global.workspace_manager.connect('notify::n-workspaces', () => { 84 | this._update('workspaces-changed', 'workspace_manager n-workspaces'); 85 | }); 86 | 87 | this._ws_active_changed = global.workspace_manager.connect( 88 | 'active-workspace-changed', 89 | () => { 90 | this._previousWorkspace = this.currentIndex; 91 | this._update( 92 | 'active-workspace-changed', 93 | 'workspace_manager active-workspace-changed', 94 | ); 95 | // We need to update names in case we moved away from the last dynamic workspace. 96 | this._smartNamesNotifier.notify(); 97 | }, 98 | ); 99 | // Additionally to tracking new windows on workspaces, we need to track windows that change 100 | // their names after being opened. 101 | this._windows_changed = Shell.WindowTracker.get_default().connect( 102 | 'tracked-windows-changed', 103 | () => { 104 | this._update('windows-changed', 'WindowTracker tracked-windows-changed'); 105 | this._smartNamesNotifier.notify(); 106 | }, 107 | ); 108 | this._settings.dynamicWorkspaces.subscribe(() => 109 | this._update('workspaces-changed', 'settings dynamicWorkspaces'), 110 | ); 111 | this._settings.workspaceNames.subscribe(() => 112 | this._update('workspace-names-changed', 'settings workspaceNames'), 113 | ); 114 | this._settings.showEmptyWorkspaces.subscribe(() => 115 | this._update('workspaces-changed', 'settings showEmptyWorkspaces'), 116 | ); 117 | hook(WindowManager, 'insertWorkspace', 'before', (_, pos: number) => { 118 | // GNOME shell calls `insertWorkspace` even when workspaces are 119 | // static. It just returns in this case. 120 | if (this._settings.dynamicWorkspaces.value) { 121 | this._wsNames?.insert(pos); 122 | } 123 | }); 124 | this._update('init', 'init'); 125 | this._settings.smartWorkspaceNames.subscribe( 126 | (value) => value && this._clearEmptyWorkspaceNames(), 127 | { emitCurrentValue: true }, 128 | ); 129 | this._settings.smartWorkspaceNames.subscribe(() => this._updateWindowAddedListeners()); 130 | this._settings.reevaluateSmartWorkspaceNames.subscribe(() => 131 | this._updateWindowAddedListeners(), 132 | ); 133 | // Update smart workspaces after a small delay because workspaces can briefly get into 134 | // inconsistent states while empty dynamic workspaces are being removed. 135 | this._smartNamesNotifier.subscribe(() => this._updateSmartWorkspaceNames()); 136 | } 137 | 138 | destroy() { 139 | this._wsNames = null; 140 | if (this._ws_changed) { 141 | global.workspace_manager.disconnect(this._ws_changed); 142 | } 143 | if (this._ws_reordered) { 144 | global.workspace_manager.disconnect(this._ws_reordered); 145 | } 146 | if (this._ws_active_changed) { 147 | global.workspace_manager.disconnect(this._ws_active_changed); 148 | } 149 | if (this._windows_changed) { 150 | Shell.WindowTracker.get_default().disconnect(this._windows_changed); 151 | } 152 | this._updateNotifier.destroy(); 153 | this._smartNamesNotifier.destroy(); 154 | this._windowChangedListeners.forEach((entry) => entry.workspace.disconnect(entry.listener)); 155 | } 156 | 157 | onUpdate(callback: () => void, until?: Subject) { 158 | this._updateNotifier.subscribe(callback, until); 159 | } 160 | 161 | /** Handles a direct switch-to-workspace command by the user. */ 162 | switchTo(index: number, cause: 'keyboard-shortcut' | 'click-on-label') { 163 | const isCurrentWorkspace = global.workspace_manager.get_active_workspace_index() === index; 164 | if (isCurrentWorkspace) { 165 | if ( 166 | this._settings.backAndForth.value && 167 | (cause === 'keyboard-shortcut' || this._settings.toggleOverview.value === false) 168 | ) { 169 | this.activatePrevious(); 170 | } else if ( 171 | cause === 'keyboard-shortcut' && 172 | this.workspaces[index].hasWindows && 173 | global.display.get_focus_window().is_on_all_workspaces() 174 | ) { 175 | const workspace = global.workspace_manager.get_workspace_by_index(index); 176 | this.focusMostRecentWindowOnWorkspace(workspace); 177 | } else if (this._settings.toggleOverview.value) { 178 | Main.overview.toggle(); 179 | } 180 | } else { 181 | this.activate(index); 182 | } 183 | } 184 | 185 | activate(index: number) { 186 | const workspace = global.workspace_manager.get_workspace_by_index(index); 187 | if (workspace) { 188 | workspace.activate(global.get_current_time()); 189 | this.focusMostRecentWindowOnWorkspace(workspace); 190 | if ( 191 | !Main.overview.visible && 192 | !this.workspaces[index].hasWindows && 193 | this._settings.toggleOverview.value 194 | ) { 195 | Main.overview.show(); 196 | } 197 | } 198 | } 199 | 200 | activatePrevious() { 201 | this.activate(this._previousWorkspace); 202 | } 203 | 204 | addWorkspace() { 205 | if (this._settings.dynamicWorkspaces.value) { 206 | this.activate(this.numberOfEnabledWorkspaces - 1); 207 | } else { 208 | this._addStaticWorkspace(); 209 | } 210 | } 211 | 212 | activateEmptyOrAdd() { 213 | const index = this.workspaces.findIndex( 214 | (workspace) => workspace.isEnabled && !workspace.hasWindows, 215 | ); 216 | if (index >= 0) { 217 | this.activate(index); 218 | } else { 219 | this._addStaticWorkspace(); 220 | } 221 | } 222 | 223 | _addStaticWorkspace() { 224 | global.workspace_manager.append_new_workspace(true, global.get_current_time()); 225 | // We want to show the overview here when the corresponding setting is 226 | // enabled, however, this doesn't play well together with activating the 227 | // newly created workspace. 228 | // 229 | // if (!Main.overview.visible && this._settings.toggleOverview.value) { 230 | // Main.overview.show(); 231 | // } 232 | } 233 | 234 | removeWorkspace(index: number) { 235 | const workspace = global.workspace_manager.get_workspace_by_index(index); 236 | if (workspace) { 237 | global.workspace_manager.remove_workspace(workspace, global.get_current_time()); 238 | } 239 | } 240 | 241 | reorderWorkspace(oldIndex: number, newIndex: number): void { 242 | const workspace = global.workspace_manager.get_workspace_by_index(oldIndex); 243 | if (workspace) { 244 | global.workspace_manager.reorder_workspace(workspace, newIndex); 245 | } 246 | } 247 | 248 | moveCurrentWorkspace(direction: -1 | 1): void { 249 | const newIndex = this.currentIndex + direction; 250 | if (newIndex >= 0 && newIndex < this.numberOfEnabledWorkspaces) { 251 | this.reorderWorkspace(this.currentIndex, newIndex); 252 | } 253 | } 254 | 255 | getDisplayName(workspace: WorkspaceState): string { 256 | if (this.isExtraDynamicWorkspace(workspace)) { 257 | return '+'; 258 | } 259 | if (this._settings.enableCustomLabel.value) { 260 | return this.getCustomDisplayName(workspace); 261 | } else { 262 | return this.getDefaultDisplayName(workspace); 263 | } 264 | } 265 | 266 | getDefaultDisplayName(workspace: WorkspaceState): string { 267 | if (workspace.name && !this._settings.alwaysShowNumbers.value) { 268 | return workspace.name; 269 | } 270 | let numberString = `${workspace.index + 1}`; 271 | if (workspace.name) { 272 | return `${numberString}: ${workspace.name}`; 273 | } else { 274 | return numberString; 275 | } 276 | } 277 | 278 | getCustomDisplayName(workspace: WorkspaceState): string { 279 | let template: string; 280 | if (workspace.name) { 281 | template = this._settings.customLabelNamed.value; 282 | } else { 283 | template = this._settings.customLabelUnnamed.value; 284 | } 285 | let total = this.numberOfEnabledWorkspaces; 286 | if ( 287 | this._settings.dynamicWorkspaces.value && 288 | this.currentIndex !== this.numberOfEnabledWorkspaces - 1 289 | ) { 290 | total = this.numberOfEnabledWorkspaces - 1; 291 | } 292 | let displayName = template 293 | .replaceAll('{{name}}', workspace.name ?? '') 294 | .replaceAll('{{number}}', `${workspace.index + 1}`) 295 | .replaceAll('{{total}}', `${total}`) 296 | .replaceAll('{{Total}}', `${this.numberOfEnabledWorkspaces}`); 297 | if (this._settings.alwaysShowNumbers.value && !template.includes('{{number}}')) { 298 | return `${workspace.index + 1}: ${displayName}`; 299 | } else { 300 | return displayName; 301 | } 302 | } 303 | 304 | focusMostRecentWindowOnWorkspace(workspace: Workspace) { 305 | const mostRecentWindowOnWorkspace = getWindows(workspace).find( 306 | (window: Window) => !window.is_on_all_workspaces(), 307 | ); 308 | if (mostRecentWindowOnWorkspace) { 309 | workspace.activate_with_focus(mostRecentWindowOnWorkspace, global.get_current_time()); 310 | } 311 | } 312 | 313 | /** 314 | * Looks for a workspace that is visible in the workspaces bar relative to the current 315 | * workspace. 316 | * 317 | * @param step indicates the direction in which to look 318 | * @returns the index of the found workspace or `null` if there is no visible workspace in the 319 | * given direction 320 | */ 321 | findVisibleWorkspace(step: -1 | 1, { wraparound = false } = {}): number | null { 322 | let index = this.currentIndex; 323 | const startingIndex = index; 324 | while (true) { 325 | index += step; 326 | if (index < 0 || index >= this.numberOfEnabledWorkspaces) { 327 | if (wraparound) { 328 | // Prevent infinite loop when there is no other workspace to go to. 329 | if (index === startingIndex) { 330 | return null; 331 | } 332 | index = 333 | (index + this.numberOfEnabledWorkspaces) % this.numberOfEnabledWorkspaces; 334 | } else { 335 | break; 336 | } 337 | } 338 | if (this.workspaces[index].isVisible) { 339 | return index; 340 | } 341 | } 342 | return null; 343 | } 344 | 345 | /** 346 | * When using dynamic workspaces, whether `workspace` is the extra last workspace, that is 347 | * currently neither used nor focused. 348 | */ 349 | isExtraDynamicWorkspace(workspace: WorkspaceState): boolean { 350 | return ( 351 | this._settings.dynamicWorkspaces.value && 352 | workspace.index > 0 && 353 | workspace.index === this.numberOfEnabledWorkspaces - 1 && 354 | !workspace.hasWindows && 355 | this.currentIndex !== workspace.index 356 | ); 357 | } 358 | 359 | /** 360 | * Updates workspaces info managed by this class. 361 | * 362 | * @param reason The external cause that makes an update necessary 363 | * @param source The unit that notified us of the change (used for debugging) 364 | */ 365 | private _update(reason: UpdateReason, source: string): void { 366 | // log('_update', reason, source); 367 | this.numberOfEnabledWorkspaces = global.workspace_manager.get_n_workspaces(); 368 | this.currentIndex = global.workspace_manager.get_active_workspace_index(); 369 | if ( 370 | this._settings.dynamicWorkspaces.value && 371 | !this._settings.showEmptyWorkspaces.value && 372 | this.currentIndex !== this.numberOfEnabledWorkspaces - 1 373 | ) { 374 | this.lastVisibleWorkspace = this.numberOfEnabledWorkspaces - 2; 375 | } else { 376 | this.lastVisibleWorkspace = this.numberOfEnabledWorkspaces - 1; 377 | } 378 | const numberOfTrackedWorkspaces = Math.max( 379 | this.numberOfEnabledWorkspaces, 380 | this._settings.workspaceNames.value.length, 381 | ); 382 | this.workspaces = [...Array(numberOfTrackedWorkspaces)].map((_, index) => 383 | this._getWorkspaceState(index), 384 | ); 385 | this._updateNotifier.notify(); 386 | 387 | if (reason === 'workspaces-changed' || reason === 'init') { 388 | this._handleWorkspacesReordered(); 389 | } 390 | if ( 391 | reason === 'workspaces-changed' || 392 | reason === 'workspace-names-changed' || 393 | reason === 'init' 394 | ) { 395 | this._updateWindowAddedListeners(); 396 | } 397 | } 398 | 399 | /** 400 | * Matches known workspaces with current workspaces to identify reordered workspaces and adapt 401 | * names accordingly. 402 | * 403 | * Also updates known workspaces. 404 | */ 405 | private _handleWorkspacesReordered(): void { 406 | const newMetaWorkspaces = this._getMetaWorkspaces(); 407 | const reorderMap: number[] = []; 408 | let hasReordered = false; 409 | for (const [index, metaWorkspace] of newMetaWorkspaces.entries()) { 410 | const oldIndex = this._metaWorkspaces.indexOf(metaWorkspace); 411 | reorderMap[index] = oldIndex; 412 | if (oldIndex !== -1 && oldIndex !== index) { 413 | hasReordered = true; 414 | } 415 | } 416 | if (hasReordered) { 417 | this._wsNames?.reorder(reorderMap); 418 | } 419 | this._metaWorkspaces = newMetaWorkspaces; 420 | } 421 | 422 | private _getMetaWorkspaces(): Meta.Workspace[] { 423 | return Array.from({ length: this.numberOfEnabledWorkspaces }).map( 424 | (_, i) => global.workspace_manager.get_workspace_by_index(i)!, 425 | ); 426 | } 427 | 428 | /** 429 | * Updates the listeners for added and removed windows on workspaces. 430 | * 431 | * Connects listeners to workspaces that newly need to be tracked and removes the ones from 432 | * workspaces that don't need tracking anymore. 433 | * 434 | * Records and updates all connected listeners in `_windowAddedListeners`. 435 | */ 436 | private _updateWindowAddedListeners() { 437 | // Listeners are only added when smart workspace names are enabled and the workspace does 438 | // not yet have a name. They are removed as soon as the setting is turned off or the 439 | // workspace is assigned a name. 440 | // 441 | // We need to track `window-added` signals in addition to `tracked-windows-changed` signals 442 | // so we catch windows that have been moved from another monitor to the primary monitor. 443 | 444 | // Add missing listeners. 445 | if (this._settings.smartWorkspaceNames.value) { 446 | for (const workspace of this.workspaces) { 447 | if ( 448 | !this._windowChangedListeners.some( 449 | (entry) => entry.workspace.index() === workspace.index, 450 | ) 451 | ) { 452 | const metaWorkspace = global.workspace_manager.get_workspace_by_index( 453 | workspace.index, 454 | ); 455 | if (metaWorkspace) { 456 | const listenerAdded = metaWorkspace.connect('window-added', () => { 457 | this._update('windows-changed', 'Workspace window-added'); 458 | this._updateSmartWorkspaceNames(); 459 | }); 460 | this._windowChangedListeners.push({ 461 | workspace: metaWorkspace, 462 | listener: listenerAdded, 463 | }); 464 | if (this._settings.reevaluateSmartWorkspaceNames.value) { 465 | const listenerRemoved = metaWorkspace.connect('window-removed', () => { 466 | this._update('windows-changed', 'Workspace window-removed'); 467 | this._updateSmartWorkspaceNames(); 468 | }); 469 | this._windowChangedListeners.push({ 470 | workspace: metaWorkspace, 471 | listener: listenerRemoved, 472 | }); 473 | } 474 | } 475 | } 476 | } 477 | } 478 | // Remove unneeded listeners. 479 | let removedListener = false; 480 | this._windowChangedListeners.forEach((entry, arrayIndex) => { 481 | const workspace = this.workspaces[entry.workspace.index()]; 482 | if ( 483 | !this._settings.smartWorkspaceNames.value || 484 | !workspace || 485 | (workspace.name && !this._settings.reevaluateSmartWorkspaceNames.value) || 486 | !workspace.isEnabled 487 | ) { 488 | entry.workspace.disconnect(entry.listener); 489 | delete this._windowChangedListeners[arrayIndex]; 490 | removedListener = true; 491 | } 492 | }); 493 | if (removedListener) { 494 | this._windowChangedListeners = this._windowChangedListeners.filter( 495 | (entry) => entry != null, 496 | ); 497 | } 498 | } 499 | 500 | private _updateSmartWorkspaceNames(): void { 501 | if (this._settings.smartWorkspaceNames.value) { 502 | for (const workspace of this.workspaces) { 503 | if ( 504 | this._settings.reevaluateSmartWorkspaceNames.value && 505 | workspace.name && 506 | !this._wsNames!.workspaceNameIsSupportedByWindows(workspace) 507 | ) { 508 | this._wsNames!.rename(workspace.index, ''); 509 | workspace.name = ''; 510 | } 511 | if (workspace.hasWindows && !workspace.name) { 512 | this._wsNames!.restoreSmartWorkspaceName(workspace.index); 513 | } 514 | if (this.isExtraDynamicWorkspace(workspace)) { 515 | this._wsNames!.remove(workspace.index); 516 | } 517 | } 518 | } 519 | } 520 | 521 | private _clearEmptyWorkspaceNames(): void { 522 | for (const workspace of this.workspaces) { 523 | if ( 524 | (!workspace.isEnabled || this.isExtraDynamicWorkspace(workspace)) && 525 | typeof workspace.name === 'string' 526 | ) { 527 | // Completely remove disabled workspaces from the names array. 528 | this._wsNames!.remove(workspace.index); 529 | } else if (!workspace.hasWindows && workspace.name) { 530 | // Keep empty workspaces in the names array to not mix up names of workspaces after. 531 | this._wsNames!.rename(workspace.index, ''); 532 | } 533 | } 534 | } 535 | 536 | private _getWorkspaceState(index: number): WorkspaceState { 537 | if (index < this.numberOfEnabledWorkspaces) { 538 | const workspace = global.workspace_manager.get_workspace_by_index(index); 539 | const hasWindows = getNumberOfWindows(workspace) > 0; 540 | return { 541 | isEnabled: true, 542 | isVisible: hasWindows || this._getIsEmptyButVisible(index), 543 | hasWindows, 544 | index, 545 | name: this._settings.workspaceNames.value[index], 546 | }; 547 | } else { 548 | return { 549 | isEnabled: false, 550 | isVisible: false, 551 | hasWindows: false, 552 | index, 553 | name: this._settings.workspaceNames.value[index], 554 | }; 555 | } 556 | } 557 | 558 | /** 559 | * @param index index of an enabled workspace that has no windows 560 | */ 561 | private _getIsEmptyButVisible(index: number): boolean { 562 | if (index === this.currentIndex) { 563 | return true; 564 | } else if ( 565 | // The last workspace for dynamic workspaces is a special case. 566 | this._settings.dynamicWorkspaces.value && 567 | !this._settings.showEmptyWorkspaces.value 568 | ) { 569 | return false; 570 | } else { 571 | return this._settings.showEmptyWorkspaces.value; 572 | } 573 | } 574 | } 575 | 576 | /** 577 | * Returns the number of windows on the given workspace, excluding windows on all workspaces, e.g., 578 | * windows on a secondary screen when workspaces do not span all screens. 579 | */ 580 | function getNumberOfWindows(workspace: Workspace) { 581 | const windows: Window[] = workspace.list_windows(); 582 | return windows.filter((window) => !window.is_on_all_workspaces()).length; 583 | } 584 | -------------------------------------------------------------------------------- /src/stylesheet.css: -------------------------------------------------------------------------------- 1 | /* Copy the appearance of the "Activities" button for indicator style "Current workspace name". */ 2 | .space-bar.workspace-label { 3 | -natural-hpadding: 18px; 4 | } 5 | 6 | .workspace-box.dragging .space-bar-workspace-label.inactive { 7 | background-color: rgb(0, 0, 0); 8 | transition: margin-left 0.5s, margin-right 0.5s; 9 | } 10 | 11 | .space-bar-menu .space-bar-menu-heading { 12 | margin-left: 8px; 13 | margin-right: 8px; 14 | font-weight: 700; 15 | font-size: 9pt; 16 | } 17 | 18 | .space-bar-menu .popup-menu-ornament { 19 | width: 0 !important; 20 | } 21 | -------------------------------------------------------------------------------- /src/types/dummy/gi/Adw.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace Adw { 2 | type ActionRow = any; 3 | type ComboRow = any; 4 | type HeaderBar = any; 5 | type PreferencesGroup = any; 6 | type PreferencesPage = any; 7 | type PreferencesWindow = any; 8 | type Toast = any; 9 | type ToastOverlay = any; 10 | } 11 | 12 | declare const Adw: any; 13 | 14 | export default Adw; 15 | -------------------------------------------------------------------------------- /src/types/dummy/gi/Clutter.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace Clutter { 2 | type Actor = any; 3 | type Event = any; 4 | } 5 | 6 | declare const Clutter: any; 7 | 8 | export default Clutter; -------------------------------------------------------------------------------- /src/types/dummy/gi/GLib.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace GLib { 2 | type Variant = any; 3 | } 4 | 5 | declare const GLib: any; 6 | 7 | export default GLib; -------------------------------------------------------------------------------- /src/types/dummy/gi/GObject.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace GObject { 2 | type Object = any; 3 | type GType = any; 4 | } 5 | 6 | declare const GObject: any; 7 | 8 | export default GObject; -------------------------------------------------------------------------------- /src/types/dummy/gi/Gdk.ts: -------------------------------------------------------------------------------- 1 | declare namespace Gdk { 2 | } 3 | 4 | declare const Gdk: any; 5 | 6 | export default Gdk; -------------------------------------------------------------------------------- /src/types/dummy/gi/Gio.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace Gio { 2 | type File = any; 3 | type ListModel = any; 4 | type Settings = any; 5 | } 6 | 7 | declare const Gio: any; 8 | 9 | export default Gio; 10 | -------------------------------------------------------------------------------- /src/types/dummy/gi/Gtk.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace Gtk { 2 | type Button = any; 3 | type EventControllerKey = any; 4 | type Dialog = any; 5 | type Label = any; 6 | type ShortcutController = any; 7 | type ShortcutManager = any; 8 | type ToggleButton = any; 9 | type Widget = any; 10 | } 11 | 12 | declare const Gtk: any; 13 | 14 | export default Gtk; 15 | -------------------------------------------------------------------------------- /src/types/dummy/gi/Meta.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace Meta { 2 | type Window = any; 3 | type Workspace = any; 4 | } 5 | 6 | declare const Meta: any; 7 | 8 | export default Meta; -------------------------------------------------------------------------------- /src/types/dummy/gi/Shell.ts: -------------------------------------------------------------------------------- 1 | declare namespace Shell { 2 | } 3 | 4 | declare const Shell: any; 5 | 6 | export default Shell; -------------------------------------------------------------------------------- /src/types/dummy/gi/St.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace St { 2 | type BoxLayout = any; 3 | type Bin = any; 4 | type Label = any; 5 | type Widget = any; 6 | } 7 | 8 | declare const St: any; 9 | 10 | export default St; -------------------------------------------------------------------------------- /src/types/dummy/shell/extensions/extension.js.d.ts: -------------------------------------------------------------------------------- 1 | export const Extension: any; -------------------------------------------------------------------------------- /src/types/dummy/shell/extensions/prefs.js.d.ts: -------------------------------------------------------------------------------- 1 | export const ExtensionPreferences: any; -------------------------------------------------------------------------------- /src/types/dummy/shell/ui/altTab.js.d.ts: -------------------------------------------------------------------------------- 1 | export const getWindows: any -------------------------------------------------------------------------------- /src/types/dummy/shell/ui/dnd.js.d.ts: -------------------------------------------------------------------------------- 1 | export const makeDraggable: any; 2 | export const addDragMonitor: any; 3 | export const removeDragMonitor: any; 4 | export const DragMotionResult: any; -------------------------------------------------------------------------------- /src/types/dummy/shell/ui/main.js.d.ts: -------------------------------------------------------------------------------- 1 | export const overview: any; 2 | export const panel: any; 3 | export const sessionMode: any; 4 | export const wm: any; -------------------------------------------------------------------------------- /src/types/dummy/shell/ui/panelMenu.js.d.ts: -------------------------------------------------------------------------------- 1 | export const Button: any; -------------------------------------------------------------------------------- /src/types/dummy/shell/ui/popupMenu.js.d.ts: -------------------------------------------------------------------------------- 1 | export const PopupMenuSection: any; 2 | export const PopupBaseMenuItem: any; 3 | export const PopupMenuItem: any; 4 | export const PopupSeparatorMenuItem: any; -------------------------------------------------------------------------------- /src/types/dummy/shell/ui/windowManager.js.d.ts: -------------------------------------------------------------------------------- 1 | export const WindowManager: any; -------------------------------------------------------------------------------- /src/types/dummy/shell/ui/windowPreview.js.d.ts: -------------------------------------------------------------------------------- 1 | export const WindowPreview: any; -------------------------------------------------------------------------------- /src/types/dummy/types.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | const global: any; 3 | } 4 | 5 | export {} -------------------------------------------------------------------------------- /src/types/generated/gi/Adw.d.ts: -------------------------------------------------------------------------------- 1 | import { Adw } from '@imports/adw-1'; 2 | export default Adw; 3 | -------------------------------------------------------------------------------- /src/types/generated/gi/Clutter.d.ts: -------------------------------------------------------------------------------- 1 | import { Clutter } from '@imports/clutter-14'; 2 | export default Clutter; 3 | -------------------------------------------------------------------------------- /src/types/generated/gi/GLib.d.ts: -------------------------------------------------------------------------------- 1 | import { GLib } from '@imports/glib-2.0'; 2 | export default GLib; 3 | -------------------------------------------------------------------------------- /src/types/generated/gi/GObject.d.ts: -------------------------------------------------------------------------------- 1 | import { GObject } from '@imports/gobject-2.0'; 2 | export default GObject; 3 | -------------------------------------------------------------------------------- /src/types/generated/gi/Gdk.d.ts: -------------------------------------------------------------------------------- 1 | import { Gdk } from '@imports/gdk-4.0'; 2 | export default Gdk; 3 | -------------------------------------------------------------------------------- /src/types/generated/gi/Gio.d.ts: -------------------------------------------------------------------------------- 1 | import { Gio } from '@imports/gio-2.0'; 2 | export default Gio; 3 | -------------------------------------------------------------------------------- /src/types/generated/gi/Gtk.d.ts: -------------------------------------------------------------------------------- 1 | import { Gtk } from '@imports/gtk-4.0'; 2 | export default Gtk; 3 | -------------------------------------------------------------------------------- /src/types/generated/gi/Meta.d.ts: -------------------------------------------------------------------------------- 1 | import { Meta } from '@imports/meta-14'; 2 | export default Meta; 3 | -------------------------------------------------------------------------------- /src/types/generated/gi/Shell.d.ts: -------------------------------------------------------------------------------- 1 | import { Shell } from '@imports/shell-14'; 2 | export default Shell; 3 | -------------------------------------------------------------------------------- /src/types/generated/gi/St.d.ts: -------------------------------------------------------------------------------- 1 | import { St } from '@imports/st-14'; 2 | export default St; 3 | -------------------------------------------------------------------------------- /src/types/generated/types.d.ts: -------------------------------------------------------------------------------- 1 | import type Meta from './gi/Meta'; 2 | 3 | declare global { 4 | const global: Global; 5 | } 6 | 7 | interface Global { 8 | display: Meta.Display; 9 | workspace_manager: Meta.WorkspaceManager; 10 | stage: Meta.Stage; 11 | get_current_time: () => number; 12 | } 13 | -------------------------------------------------------------------------------- /src/ui/WorkspacesBar.ts: -------------------------------------------------------------------------------- 1 | import Clutter from 'gi://Clutter'; 2 | import GObject from 'gi://GObject'; 3 | import type Meta from 'gi://Meta'; 4 | import St from 'gi://St'; 5 | import * as DND from 'resource:///org/gnome/shell/ui/dnd.js'; 6 | import * as Main from 'resource:///org/gnome/shell/ui/main.js'; 7 | import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js'; 8 | import { WindowPreview } from 'resource:///org/gnome/shell/ui/windowPreview.js'; 9 | import { Settings } from '../services/Settings'; 10 | import { Styles } from '../services/Styles'; 11 | import { WorkspaceState, Workspaces } from '../services/Workspaces'; 12 | import { Subject } from '../utils/Subject'; 13 | import { Timeout } from '../utils/Timeout'; 14 | import { WorkspacesBarMenu } from './WorkspacesBarMenu'; 15 | 16 | interface DragEvent { 17 | x: number; 18 | y: number; 19 | dragActor: Clutter.Actor; 20 | source?: any; 21 | targetActor: Clutter.Actor; 22 | } 23 | 24 | interface DropEvent { 25 | dropActor: Clutter.Actor; 26 | targetActor: Clutter.Actor; 27 | clutterEvent: Clutter.Event; 28 | } 29 | 30 | interface DropPosition { 31 | index: number; 32 | wsBox: St.Bin; 33 | position: 'before' | 'after'; 34 | width: number; 35 | } 36 | 37 | interface WsBoxPosition { 38 | index: number; 39 | center: number; 40 | wsBox: St.Bin; 41 | } 42 | 43 | /** 44 | * Maximum number of milliseconds between button press and button release to be recognized as click. 45 | */ 46 | const MAX_CLICK_TIME_DELTA = 300; 47 | /** 48 | * Time in milliseconds until a touch event is recognized as long press. 49 | */ 50 | const LONG_PRESS_DURATION = 500; 51 | 52 | export class WorkspacesBar { 53 | private readonly _name = `${this._extension.metadata.name}`; 54 | private readonly _settings = Settings.getInstance(); 55 | private readonly _styles = Styles.getInstance(); 56 | private readonly _ws = Workspaces.getInstance(); 57 | private _button: any; 58 | private _buttonSubject = new Subject(null); 59 | private _menu!: WorkspacesBarMenu; 60 | /** The child of `_button` when `indicator-style` is `current-workspace-name`. */ 61 | private _wsLabel?: St.Label; 62 | /** The child of `_button` when `indicator-style` is `workspaces-bar`. */ 63 | private _wsBar?: St.BoxLayout; 64 | private readonly _dragHandler = new WorkspacesBarDragHandler(() => this._updateWorkspaces()); 65 | private readonly _touchTimeout = new Timeout(); 66 | 67 | constructor(private _extension: any) {} 68 | 69 | init(): void { 70 | this._initButton(); 71 | this._initMenu(); 72 | this._ws.onUpdate(() => this._updateWorkspaces()); 73 | this._styles.onWorkspacesBarChanged(() => this._refreshTopBarConfiguration()); 74 | this._styles.onWorkspaceLabelsChanged(() => this._updateWorkspaces()); 75 | this._settings.alwaysShowNumbers.subscribe(() => this._updateWorkspaces()); 76 | this._settings.enableCustomLabel.subscribe(() => this._updateWorkspaces()); 77 | this._settings.customLabelNamed.subscribe(() => this._updateWorkspaces()); 78 | this._settings.customLabelUnnamed.subscribe(() => this._updateWorkspaces()); 79 | this._settings.indicatorStyle.subscribe(() => this._refreshTopBarConfiguration()); 80 | this._settings.position.subscribe(() => this._refreshTopBarConfiguration()); 81 | this._settings.positionIndex.subscribe(() => this._refreshTopBarConfiguration()); 82 | } 83 | 84 | destroy(): void { 85 | this._button.destroy(); 86 | this._menu.destroy(); 87 | this._dragHandler.destroy(); 88 | this._buttonSubject.complete(); 89 | this._touchTimeout.destroy(); 90 | } 91 | 92 | observeWidget(): Subject { 93 | return this._buttonSubject; 94 | } 95 | 96 | private _refreshTopBarConfiguration(): void { 97 | this._button.destroy(); 98 | this._menu.destroy(); 99 | this._initButton(); 100 | this._initMenu(); 101 | } 102 | 103 | private _initButton(): void { 104 | this._button = new (WorkspacesButton as any)(0.5, this._name); 105 | this._buttonSubject.next(this._button); 106 | this._button.styleClass = 'panel-button space-bar'; 107 | switch (this._settings.indicatorStyle.value) { 108 | case 'current-workspace': 109 | this._initWorkspaceLabel(); 110 | break; 111 | case 'workspaces-bar': 112 | this._initWorkspacesBar(); 113 | break; 114 | } 115 | Main.panel.addToStatusArea( 116 | this._name, 117 | this._button, 118 | this._settings.positionIndex.value, 119 | this._settings.position.value, 120 | ); 121 | this._updateWorkspaces(); 122 | } 123 | 124 | private _initMenu(): void { 125 | this._menu = new WorkspacesBarMenu(this._extension, this._button.menu); 126 | this._menu.init(); 127 | } 128 | 129 | private _initWorkspaceLabel() { 130 | this._button.styleClass += ' workspace-label'; 131 | this._wsLabel = new St.Label({ 132 | yAlign: Clutter.ActorAlign.CENTER, 133 | }); 134 | this._button.add_child(this._wsLabel); 135 | this._button.connect('button-press-event', (actor: any, event: Clutter.Event) => { 136 | switch (event.get_button()) { 137 | case 1: 138 | if (this._settings.toggleOverview.value) { 139 | Main.overview.toggle(); 140 | } else { 141 | this._button.menu.toggle(); 142 | } 143 | return Clutter.EVENT_STOP; 144 | case 3: 145 | this._button.menu.toggle(); 146 | return Clutter.EVENT_STOP; 147 | } 148 | return Clutter.EVENT_PROPAGATE; 149 | }); 150 | } 151 | 152 | private _initWorkspacesBar() { 153 | this._button._delegate = this._dragHandler; 154 | this._button.trackHover = false; 155 | this._wsBar = new St.BoxLayout({}); 156 | this._button.add_child(this._wsBar); 157 | } 158 | 159 | private _updateWorkspaces() { 160 | switch (this._settings.indicatorStyle.value) { 161 | case 'current-workspace': 162 | this._updateWorkspaceLabel(); 163 | break; 164 | case 'workspaces-bar': 165 | this._updateWorkspacesBar(); 166 | break; 167 | } 168 | } 169 | 170 | private _updateWorkspaceLabel() { 171 | const workspace = this._ws.workspaces[this._ws.currentIndex]; 172 | this._wsLabel!.set_text(this._ws.getDisplayName(workspace)); 173 | } 174 | 175 | private _updateWorkspacesBar() { 176 | // destroy old workspaces bar buttons 177 | this._wsBar!.destroy_all_children(); 178 | this._dragHandler.wsBoxes = []; 179 | // display all current workspaces buttons 180 | for (let ws_index = 0; ws_index < this._ws.numberOfEnabledWorkspaces; ++ws_index) { 181 | const workspace = this._ws.workspaces[ws_index]; 182 | if (workspace.isVisible) { 183 | const wsBox = this._createWsBox(workspace); 184 | this._wsBar!.add_child(wsBox); 185 | this._dragHandler.wsBoxes.push({ workspace, wsBox }); 186 | } 187 | } 188 | } 189 | 190 | private _createWsBox(workspace: WorkspaceState): St.Bin { 191 | const wsBox = new St.Bin({ 192 | visible: true, 193 | reactive: true, 194 | canFocus: true, 195 | trackHover: true, 196 | styleClass: `workspace-box workspace-box-${workspace.index + 1}`, 197 | }); 198 | (wsBox as any)._delegate = new WorkspaceBoxDragHandler(workspace); 199 | const label = this._createLabel(workspace); 200 | wsBox.set_child(label); 201 | let lastButton1PressEvent: Clutter.Event | null; 202 | wsBox.connect('button-press-event', (actor, event: Clutter.Event) => { 203 | switch (event.get_button()) { 204 | case 1: 205 | lastButton1PressEvent = event; 206 | break; 207 | case 3: 208 | this._button.menu.toggle(); 209 | break; 210 | } 211 | return Clutter.EVENT_PROPAGATE; 212 | }); 213 | // Activate workspaces on button release to not interfere with drag and drop, but make sure 214 | // we saw a corresponding button-press event to avoid activating workspaces when the click 215 | // already triggered another action like closing a menu. 216 | wsBox.connect('button-release-event', (actor, event: Clutter.Event) => { 217 | switch (event.get_button()) { 218 | case 1: 219 | if (lastButton1PressEvent) { 220 | const timeDelta = event.get_time() - lastButton1PressEvent.get_time(); 221 | if (timeDelta <= MAX_CLICK_TIME_DELTA) { 222 | this._ws.switchTo(workspace.index, 'click-on-label'); 223 | } 224 | lastButton1PressEvent = null; 225 | } 226 | break; 227 | } 228 | return Clutter.EVENT_PROPAGATE; 229 | }); 230 | let lastTouchBeginEvent: Clutter.Event | null; 231 | wsBox.connect('touch-event', (actor, event: Clutter.Event) => { 232 | switch (event.type()) { 233 | case Clutter.EventType.TOUCH_BEGIN: 234 | lastTouchBeginEvent = event; 235 | this._touchTimeout 236 | .once(LONG_PRESS_DURATION) 237 | .then(() => this._button.menu.toggle()); 238 | break; 239 | case Clutter.EventType.TOUCH_END: 240 | if (lastTouchBeginEvent) { 241 | const timeDelta = event.get_time() - lastTouchBeginEvent.get_time(); 242 | if (timeDelta <= MAX_CLICK_TIME_DELTA) { 243 | this._ws.switchTo(workspace.index, 'click-on-label'); 244 | } 245 | lastTouchBeginEvent = null; 246 | } 247 | this._touchTimeout.clearTimeout(); 248 | break; 249 | case Clutter.EventType.TOUCH_CANCEL: 250 | this._touchTimeout.clearTimeout(); 251 | break; 252 | } 253 | return Clutter.EVENT_PROPAGATE; 254 | }); 255 | this._dragHandler.setupDnd(wsBox, workspace, { 256 | onDragStart: () => this._touchTimeout.clearTimeout(), 257 | }); 258 | return wsBox; 259 | } 260 | 261 | private _createLabel(workspace: WorkspaceState): St.Label { 262 | const label = new St.Label({ 263 | yAlign: Clutter.ActorAlign.CENTER, 264 | styleClass: 'space-bar-workspace-label', 265 | }); 266 | if (workspace.index == this._ws.currentIndex) { 267 | label.styleClass += ' active'; 268 | } else { 269 | label.styleClass += ' inactive'; 270 | } 271 | if (workspace.hasWindows) { 272 | label.styleClass += ' nonempty'; 273 | } else { 274 | label.styleClass += ' empty'; 275 | } 276 | const text = this._ws.getDisplayName(workspace); 277 | label.set_text(text); 278 | if (text.trim() === '') { 279 | label.styleClass += ' no-text'; 280 | } 281 | return label; 282 | } 283 | } 284 | 285 | var WorkspacesButton = GObject.registerClass( 286 | class WorkspacesButton extends PanelMenu.Button { 287 | vfunc_event() { 288 | return Clutter.EVENT_PROPAGATE; 289 | } 290 | }, 291 | ); 292 | 293 | class WorkspacesBarDragHandler { 294 | wsBoxes: { 295 | workspace: WorkspaceState; 296 | wsBox: St.Bin; 297 | }[] = []; 298 | private readonly _ws = Workspaces.getInstance(); 299 | private _dragMonitor: any; 300 | private _draggedWorkspace?: WorkspaceState | null; 301 | private _wsBoxPositions?: WsBoxPosition[] | null; 302 | private _initialDropPosition?: DropPosition | null; 303 | private _barWidthAtDragStart: number | null = null; 304 | private _hasLeftInitialPosition = false; 305 | private _workspacesBarOffset: number | null = null; 306 | 307 | constructor(private _updateWorkspaces: () => void) {} 308 | 309 | destroy(): void { 310 | this._setDragMonitor(false); 311 | } 312 | 313 | setupDnd(wsBox: St.Bin, workspace: WorkspaceState, hooks: { onDragStart: () => void }): void { 314 | const draggable = DND.makeDraggable(wsBox, {}); 315 | draggable.connect('drag-begin', () => { 316 | this._onDragStart(wsBox, workspace); 317 | hooks.onDragStart(); 318 | }); 319 | draggable.connect('drag-cancelled', () => { 320 | this._updateDragPlaceholder(this._initialDropPosition!); 321 | this._onDragFinished(wsBox); 322 | }); 323 | draggable.connect('drag-end', () => { 324 | this._updateWorkspaces(); 325 | }); 326 | } 327 | 328 | acceptDrop(source: any, actor: Clutter.Actor, x: number, y: number): boolean { 329 | if (source instanceof WorkspaceBoxDragHandler) { 330 | const dropPosition = this._getDropPosition(); 331 | if (dropPosition) { 332 | if (this._draggedWorkspace!.index !== dropPosition?.index) { 333 | this._ws.reorderWorkspace(this._draggedWorkspace!.index, dropPosition?.index); 334 | } 335 | } 336 | this._updateWorkspaces(); 337 | this._onDragFinished(actor as St.Bin); 338 | return true; 339 | } else { 340 | return false; 341 | } 342 | } 343 | 344 | handleDragOver(source: any) { 345 | if (source instanceof WorkspaceBoxDragHandler) { 346 | const dropPosition = this._getDropPosition(); 347 | this._updateDragPlaceholder(dropPosition); 348 | } 349 | return DND.DragMotionResult.CONTINUE; 350 | } 351 | 352 | private _onDragStart(wsBox: St.Bin, workspace: WorkspaceState): void { 353 | wsBox.add_style_class_name('dragging'); 354 | this._draggedWorkspace = workspace; 355 | this._setDragMonitor(true); 356 | this._barWidthAtDragStart = this._getBarWidth(); 357 | this._setUpBoxPositions(wsBox, workspace); 358 | } 359 | 360 | private _onDragFinished(wsBox: St.Bin): void { 361 | wsBox.remove_style_class_name('dragging'); 362 | this._draggedWorkspace = null; 363 | this._wsBoxPositions = null; 364 | this._initialDropPosition = null; 365 | this._hasLeftInitialPosition = false; 366 | this._barWidthAtDragStart = null; 367 | this._setDragMonitor(false); 368 | } 369 | 370 | private _setDragMonitor(add: boolean): void { 371 | if (add) { 372 | this._dragMonitor = { 373 | dragMotion: this._onDragMotion.bind(this), 374 | }; 375 | DND.addDragMonitor(this._dragMonitor); 376 | } else if (this._dragMonitor) { 377 | DND.removeDragMonitor(this._dragMonitor); 378 | } 379 | } 380 | 381 | private _onDragMotion(dragEvent: DragEvent): void { 382 | this._updateDragPlaceholder(this._initialDropPosition!); 383 | return DND.DragMotionResult.CONTINUE; 384 | } 385 | 386 | private _setUpBoxPositions(wsBox: St.Bin, workspace: WorkspaceState) { 387 | const boxIndex = this.wsBoxes.findIndex((box) => box.workspace === workspace); 388 | this._wsBoxPositions = this._getWsBoxPositions(boxIndex, wsBox.get_width()); 389 | this._initialDropPosition = this._getDropPosition(); 390 | this._updateDragPlaceholder(this._initialDropPosition); 391 | } 392 | 393 | private _getDropPosition(): DropPosition | undefined { 394 | const draggedWsBox = this.wsBoxes.find( 395 | ({ workspace }) => workspace === this._draggedWorkspace, 396 | )?.wsBox as St.Bin; 397 | for (const { index, center, wsBox } of this._wsBoxPositions!) { 398 | if (draggedWsBox.get_x() < center + this._getWorkspacesBarOffset()) { 399 | return { index, wsBox, position: 'before', width: draggedWsBox.get_width() }; 400 | } 401 | } 402 | if (this._wsBoxPositions!.length > 0) { 403 | const lastWsBox = this._wsBoxPositions![this._wsBoxPositions!.length - 1].wsBox; 404 | return { 405 | index: this._ws.lastVisibleWorkspace, 406 | wsBox: lastWsBox, 407 | position: 'after', 408 | width: draggedWsBox.get_width(), 409 | }; 410 | } 411 | } 412 | 413 | private _getWsBoxPositions(draggedBoxIndex: number, draggedBoxWidth: number): WsBoxPosition[] { 414 | const positions = this.wsBoxes 415 | .filter(({ workspace }) => workspace !== this._draggedWorkspace) 416 | .map(({ workspace, wsBox }) => ({ 417 | index: getDropIndex(this._draggedWorkspace as WorkspaceState, workspace), 418 | center: getHorizontalCenter(wsBox), 419 | wsBox, 420 | })); 421 | positions.forEach((position, index) => { 422 | if (index >= draggedBoxIndex) { 423 | position.center -= draggedBoxWidth; 424 | } 425 | }); 426 | return positions; 427 | } 428 | 429 | private _updateDragPlaceholder(dropPosition?: DropPosition): void { 430 | if ( 431 | dropPosition?.index === this._initialDropPosition?.index && 432 | dropPosition?.position === this._initialDropPosition?.position 433 | ) { 434 | if (!this._getHasLeftInitialPosition()) { 435 | return; 436 | } 437 | } else { 438 | this._hasLeftInitialPosition = true; 439 | } 440 | for (const { wsBox } of this.wsBoxes) { 441 | if (wsBox === dropPosition?.wsBox) { 442 | if (dropPosition!.position === 'before') { 443 | wsBox?.set_style('margin-left: ' + dropPosition!.width + 'px'); 444 | } else { 445 | wsBox?.set_style('margin-right: ' + dropPosition!.width + 'px'); 446 | } 447 | } else { 448 | wsBox.set_style(null); 449 | } 450 | } 451 | } 452 | 453 | private _getBarWidth(): number { 454 | return this.wsBoxes[0].wsBox.get_parent()!.get_width(); 455 | } 456 | 457 | private _getHasLeftInitialPosition(): boolean { 458 | if (this._hasLeftInitialPosition) { 459 | return true; 460 | } 461 | if (this._barWidthAtDragStart !== this._getBarWidth()) { 462 | this._hasLeftInitialPosition = true; 463 | } 464 | return this._hasLeftInitialPosition; 465 | } 466 | 467 | private _getWorkspacesBarOffset(): number { 468 | if (this._workspacesBarOffset === null) { 469 | this._workspacesBarOffset = 0; 470 | let widget = this.wsBoxes[0].wsBox.get_parent(); 471 | while (widget) { 472 | this._workspacesBarOffset += widget.get_x(); 473 | widget = widget.get_parent(); 474 | } 475 | } 476 | return this._workspacesBarOffset; 477 | } 478 | } 479 | 480 | class WorkspaceBoxDragHandler { 481 | constructor(private readonly _workspace: WorkspaceState) {} 482 | 483 | acceptDrop(source: any) { 484 | if (source instanceof WindowPreview) { 485 | (source.metaWindow as Meta.Window).change_workspace_by_index( 486 | this._workspace.index, 487 | false, 488 | ); 489 | } 490 | } 491 | 492 | handleDragOver(source: any) { 493 | if (source instanceof WindowPreview) { 494 | return DND.DragMotionResult.MOVE_DROP; 495 | } else { 496 | return DND.DragMotionResult.CONTINUE; 497 | } 498 | } 499 | } 500 | 501 | function getDropIndex(draggedWorkspace: WorkspaceState, workspace: WorkspaceState): number { 502 | if (draggedWorkspace.index < workspace.index) { 503 | return workspace.index - 1; 504 | } else { 505 | return workspace.index; 506 | } 507 | } 508 | 509 | function getHorizontalCenter(widget: St.Widget): number { 510 | return widget.get_x() + widget.get_width() / 2; 511 | } 512 | -------------------------------------------------------------------------------- /src/ui/WorkspacesBarMenu.ts: -------------------------------------------------------------------------------- 1 | import Clutter from 'gi://Clutter'; 2 | import GObject from 'gi://GObject'; 3 | import St from 'gi://St'; 4 | import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; 5 | import { KeyBindings } from '../services/KeyBindings'; 6 | import { Settings } from '../services/Settings'; 7 | import { WorkspaceNames } from '../services/WorkspaceNames'; 8 | import { WorkspaceState, Workspaces } from '../services/Workspaces'; 9 | 10 | export class WorkspacesBarMenu { 11 | private readonly _keyBindings = KeyBindings.getInstance(); 12 | private readonly _settings = Settings.getInstance(); 13 | private readonly _ws = Workspaces.getInstance(); 14 | private readonly _wsNames = WorkspaceNames.getInstance(); 15 | 16 | private _hiddenWorkspacesSection = new PopupMenu.PopupMenuSection(); 17 | private _manageWorkspaceSection = new PopupMenu.PopupMenuSection(); 18 | 19 | constructor(private readonly _extension: any, private readonly _menu: any) {} 20 | 21 | init(): void { 22 | this._menu.box.add_style_class_name('space-bar-menu'); 23 | this._addSectionHeading('Rename current workspace'); 24 | this._initEntry(); 25 | this._menu.addMenuItem(this._hiddenWorkspacesSection); 26 | this._initManageWorkspaceSection(); 27 | this._initExtensionSettingsButton(); 28 | this._menu.connect('open-state-changed', () => { 29 | if (this._menu.isOpen) { 30 | this._refreshMenu(); 31 | } 32 | }); 33 | this._keyBindings.addKeyBinding('open-menu', () => this._menu.open()); 34 | } 35 | 36 | destroy(): void { 37 | this._keyBindings.removeKeybinding('open-menu'); 38 | } 39 | 40 | private _refreshMenu() { 41 | this._refreshHiddenWorkspaces(); 42 | this._refreshManageWorkspaceSection(); 43 | } 44 | 45 | private _addSectionHeading(text: string, section?: any): void { 46 | const separator = new PopupMenu.PopupSeparatorMenuItem(text); 47 | separator.label.add_style_class_name('space-bar-menu-heading'); 48 | (section ?? this._menu).addMenuItem(separator); 49 | } 50 | 51 | private _initEntry(): void { 52 | const entryItem = new PopupMenuItemEntry(); 53 | entryItem.entry.connect('key-focus-in', () => { 54 | const text = entryItem.entry.get_text(); 55 | if (text.length > 0) { 56 | entryItem.entry.get_clutter_text().set_selection(0, text.length); 57 | } 58 | }); 59 | entryItem.entry.get_clutter_text().connect('activate', () => this._menu.close()); 60 | entryItem.connect('notify::active', () => { 61 | if (entryItem.active) { 62 | entryItem.entry.grab_key_focus(); 63 | } 64 | }); 65 | let oldName = ''; 66 | this._menu.connect('open-state-changed', () => { 67 | if (this._menu.isOpen) { 68 | oldName = this._ws.workspaces[this._ws.currentIndex].name || ''; 69 | // Reset the selection before setting the text since the entry field won't let us do 70 | // that when it is empty. 71 | entryItem.entry.get_clutter_text().set_selection(0, 0); 72 | entryItem.entry.set_text(oldName); 73 | entryItem.active = true; 74 | } else { 75 | const newName = entryItem.entry.get_text(); 76 | if (newName !== oldName) { 77 | this._wsNames.rename(this._ws.currentIndex, newName); 78 | } 79 | } 80 | }); 81 | this._menu.addMenuItem(entryItem); 82 | } 83 | 84 | private _initManageWorkspaceSection() { 85 | const separator = new PopupMenu.PopupSeparatorMenuItem(); 86 | this._menu.addMenuItem(separator); 87 | this._menu.addMenuItem(this._manageWorkspaceSection); 88 | } 89 | 90 | private _initExtensionSettingsButton(): void { 91 | const separator = new PopupMenu.PopupSeparatorMenuItem(); 92 | this._menu.addMenuItem(separator); 93 | const button = new PopupMenu.PopupMenuItem(`${this._extension.metadata.name} settings`); 94 | button.connect('activate', () => { 95 | this._menu.close(); 96 | this._extension.openPreferences(); 97 | }); 98 | this._menu.addMenuItem(button); 99 | } 100 | 101 | private _refreshHiddenWorkspaces(): void { 102 | this._hiddenWorkspacesSection.box.destroy_all_children(); 103 | 104 | let hiddenWorkspaces: WorkspaceState[]; 105 | switch (this._settings.indicatorStyle.value) { 106 | case 'current-workspace': 107 | hiddenWorkspaces = this._ws.workspaces.filter( 108 | (workspace) => 109 | workspace.isEnabled && 110 | workspace.index !== this._ws.currentIndex && 111 | !this._ws.isExtraDynamicWorkspace(workspace), 112 | ); 113 | break; 114 | case 'workspaces-bar': 115 | if ( 116 | this._settings.showEmptyWorkspaces.value || 117 | this._settings.dynamicWorkspaces.value 118 | ) { 119 | return; 120 | } 121 | hiddenWorkspaces = this._ws.workspaces.filter( 122 | (workspace) => 123 | workspace.isEnabled && 124 | !workspace.hasWindows && 125 | workspace.index !== this._ws.currentIndex, 126 | ); 127 | break; 128 | } 129 | if (hiddenWorkspaces.length > 0) { 130 | this._addSectionHeading('Other workspaces', this._hiddenWorkspacesSection); 131 | hiddenWorkspaces.forEach((workspace) => { 132 | let label: string; 133 | if (this._settings.enableCustomLabelInMenus.value) { 134 | label = this._ws.getDisplayName(workspace); 135 | } else { 136 | label = this._ws.getDefaultDisplayName(workspace); 137 | } 138 | const button = new PopupMenu.PopupMenuItem(label); 139 | button.connect('activate', () => { 140 | this._menu.close(); 141 | this._ws.activate(workspace.index); 142 | }); 143 | this._hiddenWorkspacesSection.addMenuItem(button); 144 | }); 145 | } 146 | } 147 | 148 | private _refreshManageWorkspaceSection() { 149 | this._manageWorkspaceSection.box.destroy_all_children(); 150 | 151 | if ( 152 | !this._settings.dynamicWorkspaces.value || 153 | !this._settings.showEmptyWorkspaces.value || 154 | this._settings.indicatorStyle.value === 'current-workspace' 155 | ) { 156 | const newWorkspaceButton = new PopupMenu.PopupMenuItem('Add new workspace'); 157 | newWorkspaceButton.connect('activate', () => { 158 | this._menu.close(); 159 | this._ws.addWorkspace(); 160 | }); 161 | this._manageWorkspaceSection.addMenuItem(newWorkspaceButton); 162 | } 163 | const closeWorkspaceButton = new PopupMenu.PopupMenuItem('Remove current workspace'); 164 | closeWorkspaceButton.connect('activate', () => { 165 | this._ws.removeWorkspace(this._ws.currentIndex); 166 | }); 167 | this._manageWorkspaceSection.addMenuItem(closeWorkspaceButton); 168 | } 169 | } 170 | 171 | const PopupMenuItemEntry = GObject.registerClass( 172 | class PopupMenuItem extends PopupMenu.PopupBaseMenuItem { 173 | _init(params: any) { 174 | super._init(params); 175 | this.entry = new St.Entry({ 176 | xExpand: true, 177 | }); 178 | this.entry.connect('button-press-event', () => { 179 | return Clutter.EVENT_STOP; 180 | }); 181 | this.add_child(this.entry); 182 | } 183 | }, 184 | ); 185 | -------------------------------------------------------------------------------- /src/utils/DebouncingNotifier.ts: -------------------------------------------------------------------------------- 1 | import GLib from 'gi://GLib'; 2 | import { Subject } from './Subject'; 3 | 4 | /** 5 | * A subscribe/notify mechanism that debounces multiple subsequent notify calls. 6 | */ 7 | export class DebouncingNotifier { 8 | private _subscribers: (() => void)[] = []; 9 | private _timeout: number | null = null; 10 | 11 | constructor(private _delayMs: number = 0) {} 12 | 13 | notify(): void { 14 | if (this._timeout) { 15 | return; 16 | } 17 | this._timeout = GLib.timeout_add(GLib.PRIORITY_DEFAULT, this._delayMs, () => { 18 | this._notify(); 19 | this._timeout = null; 20 | return GLib.SOURCE_REMOVE; 21 | }); 22 | } 23 | 24 | subscribe(callback: () => void, until?: Subject): void { 25 | this._subscribers.push(callback); 26 | until?.subscribe( 27 | () => (this._subscribers = this._subscribers.filter((s) => s !== callback)), 28 | ); 29 | } 30 | 31 | destroy(): void { 32 | if (this._timeout) { 33 | GLib.Source.remove(this._timeout); 34 | this._timeout = null; 35 | } 36 | this._subscribers = []; 37 | } 38 | 39 | private _notify(): void { 40 | for (const subscriber of this._subscribers) { 41 | subscriber(); 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/utils/Subject.ts: -------------------------------------------------------------------------------- 1 | export class Subject { 2 | get value(): T { 3 | return this._value; 4 | } 5 | 6 | private _value: T; 7 | private _observers: ((value: T) => void)[] = []; 8 | 9 | constructor(value: T) { 10 | this._value = value; 11 | } 12 | 13 | next(value: T): void { 14 | this._value = value; 15 | for (const observer of this._observers) { 16 | observer(value); 17 | } 18 | } 19 | 20 | complete(): void { 21 | this._observers = []; 22 | } 23 | 24 | subscribe(callback: (value: T) => void): void { 25 | this._observers.push(callback); 26 | callback(this._value); 27 | } 28 | 29 | unsubscribe(callback: (value: T) => void): void { 30 | this._observers = this._observers.filter((cb) => cb !== callback); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/utils/Timeout.ts: -------------------------------------------------------------------------------- 1 | import GLib from 'gi://GLib'; 2 | 3 | export class Timeout { 4 | private _timeoutId: number | null = null; 5 | 6 | destroy(): void { 7 | this.clearTimeout(); 8 | } 9 | 10 | tick() { 11 | return new Promise((resolve) => { 12 | this.clearTimeout(); 13 | this._timeoutId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 0, () => { 14 | this._timeoutId = null; 15 | resolve(); 16 | return GLib.SOURCE_REMOVE; 17 | }); 18 | }); 19 | } 20 | 21 | once(milliseconds: number) { 22 | return new Promise((resolve) => { 23 | this.clearTimeout(); 24 | this._timeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, milliseconds, () => { 25 | this._timeoutId = null; 26 | resolve(); 27 | return GLib.SOURCE_REMOVE; 28 | }); 29 | }); 30 | } 31 | 32 | clearTimeout() { 33 | if (this._timeoutId) { 34 | GLib.Source.remove(this._timeoutId); 35 | this._timeoutId = null; 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/utils/Widget.ts: -------------------------------------------------------------------------------- 1 | import St from 'gi://St'; 2 | import { Subject } from './Subject'; 3 | 4 | export function onDestroyed(widget: St.Widget): Subject { 5 | const subject = new Subject(void 0); 6 | widget.connect('destroy', () => { 7 | subject.next(); 8 | subject.complete(); 9 | }); 10 | return subject; 11 | } 12 | -------------------------------------------------------------------------------- /src/utils/hook.ts: -------------------------------------------------------------------------------- 1 | interface IPrototype { 2 | prototype: any; 3 | } 4 | 5 | let _destroyFunctions: (() => void)[] = []; 6 | 7 | /** 8 | * Calls `callback` when the given upstream function gets called. 9 | */ 10 | export function hook< 11 | F extends string, 12 | Args extends any[], 13 | R extends any, 14 | C extends { [f in F]: (...args: Args) => R } & IPrototype, 15 | >( 16 | classObject: C, 17 | functionName: F, 18 | pos: 'before' | 'after', 19 | callback: (self: C, ...args: Args) => void, 20 | ) { 21 | const _originalFunction = classObject.prototype[functionName]; 22 | if (pos === 'before') { 23 | classObject.prototype[functionName] = function (...args: Args) { 24 | callback(this, ...args); 25 | _originalFunction.apply(this, args); 26 | }; 27 | } else { 28 | classObject.prototype[functionName] = function (...args: Args) { 29 | _originalFunction.apply(this, args); 30 | callback(this, ...args); 31 | }; 32 | } 33 | _destroyFunctions.push(() => { 34 | classObject.prototype[functionName] = _originalFunction; 35 | }); 36 | } 37 | 38 | export function destroyAllHooks() { 39 | for (const f of _destroyFunctions) { 40 | f(); 41 | } 42 | _destroyFunctions = []; 43 | } 44 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "noImplicitAny": false, 6 | "paths": { 7 | "gi://*": ["./src/types/dummy/gi/*"], 8 | "resource:///org/gnome/shell/*": ["./src/types/dummy/shell/*"], 9 | "resource:///org/gnome/Shell/Extensions/js/*": ["./src/types/dummy/shell/*"] 10 | } 11 | }, 12 | "exclude": ["src/types/generated/*"] 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2021", 4 | "strict": true, 5 | "outDir": "./target", 6 | "lib": ["ES2021", "DOM"], 7 | "paths": { 8 | "@imports/*": ["./@types/*"], 9 | "gi://*": ["./src/types/generated/gi/*"], 10 | "resource:///org/gnome/shell/*": ["./src/types/dummy/shell/*"], 11 | "resource:///org/gnome/Shell/Extensions/js/*": ["./src/types/dummy/shell/*"] 12 | } 13 | }, 14 | "include": ["src/**/*.ts"], 15 | "exclude": ["src/types/dummy/*"] 16 | } 17 | --------------------------------------------------------------------------------