├── .editorconfig ├── .github └── funding.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .vscode └── settings.json ├── LICENSE ├── Makefile ├── README.md ├── ambient.d.ts ├── eslint.config.mjs ├── extension ├── assets │ ├── arrow1-left-symbolic.svg │ └── arrow1-right-symbolic.svg ├── common │ ├── settings.ts │ └── utils │ │ └── logging.ts ├── constants.ts ├── extension.ts ├── prefs.ts ├── prefs │ ├── appGestures.ts │ └── pref.ts ├── schemas │ └── org.gnome.shell.extensions.touchpad-gesture-customization.gschema.xml ├── src │ ├── altTab.ts │ ├── animations │ │ └── arrow.ts │ ├── forwardBack.ts │ ├── gestures.ts │ ├── overviewRoundTrip.ts │ ├── pinchGestures │ │ ├── closeWindow.ts │ │ ├── pinchTracker.ts │ │ └── showDesktop.ts │ ├── snapWidnow.ts │ ├── swipeTracker.ts │ ├── utils │ │ ├── environment.ts │ │ └── keyboard.ts │ └── volumeControl.ts ├── stylesheet.css ├── types │ ├── global.d.ts │ └── gnome-shell │ │ ├── misc │ │ └── utils.d.ts │ │ └── ui │ │ ├── altTab.d.ts │ │ ├── main.d.ts │ │ ├── overviewControls.d.ts │ │ ├── swipeTracker.d.ts │ │ └── workspaceAnimation.d.ts └── ui │ ├── customizations.ui │ ├── gestures.ui │ ├── style-dark.css │ └── style.css ├── metadata.json ├── package-lock.json ├── package.json └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | 7 | indent_style = space 8 | trim_trailing_whitespace = true 9 | 10 | [*.js] 11 | indent_size = 4 12 | 13 | [*.[ch]] 14 | indent_size = 2 15 | 16 | [*.gresource.xml] 17 | indent_size = 2 18 | 19 | [*.ui] 20 | indent_size = 2 21 | 22 | [meson.build] 23 | indent_size = 2 24 | -------------------------------------------------------------------------------- /.github/funding.yml: -------------------------------------------------------------------------------- 1 | # Support the author by using the funding link below. 2 | # Maintained by Hieu Trung Nguyen with the help of awsome contributors. 3 | # GitHub Sponsors: https://github.com/sponsors/HieuTNg 4 | # Buy Me a Coffee: https://buymeacoffee.com/hieutng 5 | # Kofi: https://ko-fi.com/hieutng 6 | 7 | github: hieutng 8 | buy_me_a_coffee: hieutng 9 | ko_fi: hieutng 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | node_modules/ -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore artifacts: 2 | node_modules 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "useTabs": false, 4 | "semi": true, 5 | "singleQuote": true, 6 | "quoteProps": "as-needed", 7 | "trailingComma": "es5", 8 | "bracketSpacing": false, 9 | "arrowParens": "avoid" 10 | } 11 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | } -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | NAME=touchpad-gesture-customization 2 | DOMAIN=coooolapps.com 3 | UUID=${NAME}@${DOMAIN} 4 | BUILDIR=build 5 | ZIPPATH=${BUILDIR}/${UUID}.zip 6 | 7 | .PHONY: pack update 8 | 9 | ${SCHEMAS_DIR}/gschemas.compiled: ${SCHEMAS_DIR}/org.gnome.shell.extensions.$(NAME).gschema.xml 10 | 11 | 12 | pack: 13 | mkdir -p ${BUILDIR} 14 | cp -r extension/assets extension/stylesheet.css extension/ui extension/schemas metadata.json $(BUILDIR) 15 | glib-compile-schemas --strict ${BUILDIR}/schemas 16 | rm -f ${ZIPPATH} 17 | (cd ${BUILDIR} && zip -r ${UUID}.zip .) 18 | 19 | update: 20 | gnome-extensions install -f ${ZIPPATH} -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Touchpad Gesture Customization 2 | This extension modifies and extends existing touchpad gestures on GNOME using Wayland. This project is a fork of [gnome-gesture-improvements](https://github.com/harshadgavali/gnome-gesture-improvements). Since the original project seems to be no longer maintained, I setup this project with the aim of taking over the development and maintenance of this wonderful extension that I relied on for daily use. 3 | 4 | **Note**: I have removed the support for X11 since I only use Wayland, but this can be added again in the future if needed and if someone is willing to support this. 5 | 6 | ## Installation 7 | ### From GNOME Extensions Website 8 | 9 | Get it on EGO 10 | 11 | 12 | ### Manually 13 | 1. Install extension 14 | ``` 15 | git clone https://github.com/HieuTNg/touchpad-gesture-customization.git 16 | cd touchpad-gesture-customization 17 | npm install 18 | npm run update 19 | ``` 20 | 2. Log out and log in 21 | 3. Enable extension via extensions app or via command line 22 | ``` 23 | gnome-extensions enable touchpad-gesture-customization@coooolapps.com 24 | ``` 25 | 26 | ## Gestures (including built-in ones) 27 | | Swipe Gesture | Modes | Fingers | Direction | 28 | | :-------------------------------------- | :------- | :------- | :------------------ | 29 | | Desktop/Overview/AppGrid navigation | Any | 3/4/both | Vertical/Horizontal | 30 | | Switch workspaces | Overview | 2|3|4 | Horizontal | 31 | | Switch workspaces | Any | 3/4/both | Vertical/Horizontal | 32 | | Switch app pages | AppGrid | 2/3 | Horizontal | 33 | | Switch windows | Desktop | 3/4/both | Vertical/Horizontal | 34 | | Unmaximize/maximize/fullscreen a window | Desktop | 3/4/both | Vertical | 35 | | Minimize a window | Desktop | 3/4/both | Vertical | 36 | | Snap/half-tile a window | Desktop | 3/4/both | Vertical (*) | 37 | | Volume Control (experimental) | Desktop | 3/4/both | Vertical/Horizontal | 38 | 39 | | Pinch Gesture | Modes | Fingers | 40 | | :----------------- | :------ | :------ | 41 | | Show Desktop (*) | Desktop | 3/4 | 42 | | Close Window | Desktop | 3/4 | 43 | | Close Tab/Document | Desktop | 3/4 | 44 | 45 | | Application Gestures (Configurable) (*) | 46 | | :----------------------------------------------- | 47 | | Go back or forward in browser tab | 48 | | Page up/down | 49 | | Switch to next or previous image in image viewer | 50 | | Switch to next or previous audio | 51 | | Change tabs | 52 | 53 | #### For activating snapping/tiling gesture (inverted T gesture) 54 | 1. Do a 3/4-fingers vertical swipe downward gesture on a unmaximized window but don't release the gesture 55 | 2. Wait a few milliseconds 56 | 3. Do a 3/4-fingers horizontal swipe gesture to tile a window to either side of the screen 57 | 58 | #### For activating application gesture 59 | 1. Activating a 3/4-fingers hold gesture on touchpad by pressing your fingers on touchpad but don't release the gesture 60 | 2. Wait a few milliseconds 61 | 3. Do a 3/4-fingers horizontal swipe gesture to activate application gesture (an arrow animation cicle will appear) 62 | 63 | #### Application Gesture Notes 64 | * For horizontal gestures, application gesture only works if 3/4-fingers horizontal swipe is set to **Window Swithing** 65 | * Application gesture also supports vertical swipe but is still experimental and requires users to turn off other actions for 3/4-figners vertical swipe (i.e. set the action to None). 66 | 67 | #### Notes 68 | * Enbaling minimising window gesture for Window Manipulation will disable snapping/tiling gesture. 69 | * If you are using an older version of GNOME, there might be a bug which prevent the extension from detecting **hold and swipe gesture** and **pich gesture**. If you face this problem, the gesture can only work if the mouse pointer is pointed at the desktop or top panel. 70 | 71 | ## Customization 72 | * To switch to windows from *all* workspaces using 3-fingers swipes, run 73 | ``` 74 | gsettings set org.gnome.shell.window-switcher current-workspace-only false 75 | ``` 76 | 77 | # Acknowledgement 78 | Massive thanks to the original author and everyone who has contributed to the original project to bring us this wonderful GNOME extension. 79 | -------------------------------------------------------------------------------- /ambient.d.ts: -------------------------------------------------------------------------------- 1 | import "@girs/gjs"; 2 | import "@girs/gjs/dom"; 3 | import "@girs/gnome-shell/ambient"; 4 | import "@girs/gnome-shell/extensions/global"; -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import eslint from '@eslint/js'; 2 | import tseslint from 'typescript-eslint'; 3 | // import jsdoc from 'eslint-plugin-jsdoc'; 4 | import stylistic from '@stylistic/eslint-plugin' 5 | 6 | export default tseslint.config( 7 | eslint.configs.recommended, 8 | tseslint.configs.recommended, 9 | { 10 | plugins: { 11 | '@stylistic': stylistic, 12 | }, 13 | rules: { 14 | 'padding-line-between-statements': [ 15 | 'error', 16 | {'blankLine': 'always', 'prev': '*', 'next': ['function', 'class', 'block-like']}, 17 | {'blankLine': 'always', 'prev': ['function', 'class', 'block-like', 'import'], 'next': '*'}, 18 | {'blankLine': 'never', 'prev': 'import', 'next': 'import'} 19 | ], 20 | 'lines-around-comment': [ 21 | 'error', 22 | { 23 | 'beforeBlockComment': true, 24 | 'beforeLineComment': true, 25 | 'allowBlockStart': true 26 | } 27 | ], 28 | 'no-multiple-empty-lines': [ 29 | 'error', 30 | {"max": 1} 31 | ], 32 | 'lines-between-class-members': [ 33 | 'error', 34 | { 35 | 'enforce': [ 36 | {blankLine: "never", prev: "field", next: "field"}, 37 | {blankLine: "always", prev: "*", next: "method"}, 38 | {blankLine: "always", prev: "method", next: "field"}, 39 | ] 40 | } 41 | ], 42 | 'padded-blocks': [ 43 | 'error', 44 | {"classes": "always"} 45 | ], 46 | } 47 | }, 48 | { 49 | ignores: ['./node_modules/'], 50 | rules: { 51 | '@typescript-eslint/no-unused-vars': 'off', 52 | 'no-undef': 'off', 53 | } 54 | }, 55 | ); -------------------------------------------------------------------------------- /extension/assets/arrow1-left-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 14 | 16 | 34 | 40 | 45 | 46 | 48 | 51 | 55 | 56 | 57 | 59 | 62 | 63 | 65 | 68 | 72 | 73 | 74 | 76 | 79 | 80 | 82 | 85 | 89 | 90 | 91 | 93 | 96 | 97 | 99 | 102 | 106 | 107 | 108 | 110 | 113 | 114 | 116 | 119 | 123 | 124 | 125 | 127 | 130 | 131 | 133 | 136 | 140 | 141 | 142 | 144 | 147 | 148 | 150 | 153 | 157 | 158 | 159 | 161 | 164 | 165 | 167 | 170 | 174 | 175 | 176 | 178 | 181 | 182 | 184 | 187 | 191 | 192 | 193 | 195 | 198 | 199 | 201 | 204 | 208 | 209 | 210 | 212 | 215 | 216 | 218 | 221 | 225 | 226 | 227 | 229 | 232 | 233 | 235 | 238 | 242 | 243 | 244 | 246 | 249 | 250 | 252 | 255 | 259 | 260 | 261 | 263 | 266 | 267 | 271 | 276 | 280 | 281 | 286 | 291 | 292 | 297 | 302 | 303 | 308 | 313 | 314 | 319 | 324 | 325 | 330 | 335 | 336 | 341 | 346 | 347 | 352 | 356 | 357 | 362 | 366 | 367 | 372 | 376 | 377 | 382 | 386 | 387 | 392 | 396 | 397 | 402 | 406 | 407 | 408 | -------------------------------------------------------------------------------- /extension/assets/arrow1-right-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 14 | 16 | 34 | 40 | 45 | 46 | 48 | 51 | 55 | 56 | 57 | 59 | 62 | 63 | 65 | 68 | 72 | 73 | 74 | 76 | 79 | 80 | 82 | 85 | 89 | 90 | 91 | 93 | 96 | 97 | 99 | 102 | 106 | 107 | 108 | 110 | 113 | 114 | 116 | 119 | 123 | 124 | 125 | 127 | 130 | 131 | 133 | 136 | 140 | 141 | 142 | 144 | 147 | 148 | 150 | 153 | 157 | 158 | 159 | 161 | 164 | 165 | 167 | 170 | 174 | 175 | 176 | 178 | 181 | 182 | 184 | 187 | 191 | 192 | 193 | 195 | 198 | 199 | 201 | 204 | 208 | 209 | 210 | 212 | 215 | 216 | 218 | 221 | 225 | 226 | 227 | 229 | 232 | 233 | 235 | 238 | 242 | 243 | 244 | 246 | 249 | 250 | 252 | 255 | 259 | 260 | 261 | 263 | 266 | 267 | 271 | 276 | 280 | 281 | 286 | 291 | 292 | 297 | 302 | 303 | 308 | 313 | 314 | 319 | 324 | 325 | 330 | 335 | 336 | 341 | 346 | 347 | 352 | 356 | 357 | 362 | 366 | 367 | 372 | 376 | 377 | 382 | 386 | 387 | 392 | 396 | 397 | 402 | 406 | 407 | 408 | -------------------------------------------------------------------------------- /extension/common/settings.ts: -------------------------------------------------------------------------------- 1 | import Gio from 'gi://Gio'; 2 | import GLib from 'gi://GLib'; 3 | 4 | export enum PinchGestureType { 5 | NONE = 0, 6 | SHOW_DESKTOP = 1, 7 | CLOSE_WINDOW = 2, 8 | CLOSE_DOCUMENT = 3, 9 | } 10 | 11 | export enum SwipeGestureType { 12 | NONE = 0, 13 | OVERVIEW_NAVIGATION = 1, 14 | WORKSPACE_SWITCHING = 2, 15 | WINDOW_SWITCHING = 3, 16 | VOLUME_CONTROL = 4, 17 | WINDOW_MANIPULATION = 5, 18 | } 19 | 20 | export enum OverviewNavigationState { 21 | CYCLIC = 0, 22 | GNOME = 1, 23 | WINDOW_PICKER_ONLY = 2, 24 | } 25 | 26 | export enum ForwardBackKeyBinds { 27 | Default = 0, 28 | 'Forward/Backward' = 1, 29 | 'Page Up/Down' = 2, 30 | 'Right/Left' = 3, 31 | 'Audio Next/Prev' = 4, 32 | 'Tab Next/Prev' = 5, 33 | } 34 | 35 | export type BooleanSettingsKeys = 36 | | 'allow-minimize-window' 37 | | 'follow-natural-scroll' 38 | | 'enable-forward-back-gesture' 39 | | 'default-overview-gesture-direction' 40 | | 'enable-vertical-app-gesture'; 41 | 42 | export type IntegerSettingsKeys = 'alttab-delay' | 'hold-swipe-delay-duration'; 43 | 44 | export type DoubleSettingsKeys = 45 | | 'touchpad-speed-scale' 46 | | 'touchpad-pinch-speed' 47 | | 'volume-control-speed'; 48 | 49 | export type EnumSettingsKeys = 50 | | 'vertical-swipe-3-fingers-gesture' 51 | | 'horizontal-swipe-3-fingers-gesture' 52 | | 'vertical-swipe-4-fingers-gesture' 53 | | 'horizontal-swipe-4-fingers-gesture' 54 | | 'pinch-3-finger-gesture' 55 | | 'pinch-4-finger-gesture' 56 | | 'overview-navigation-states'; 57 | 58 | export type MiscSettingsKeys = 'forward-back-application-keyboard-shortcuts'; 59 | 60 | export type AllSettingsKeys = 61 | | BooleanSettingsKeys 62 | | IntegerSettingsKeys 63 | | DoubleSettingsKeys 64 | | EnumSettingsKeys 65 | | MiscSettingsKeys; 66 | 67 | export type UIPageObjectIds = 'gestures_page' | 'customizations_page'; 68 | 69 | export type AllUIObjectKeys = 70 | | UIPageObjectIds 71 | | AllSettingsKeys 72 | | 'touchpad-speed-scale_display-value' 73 | | 'touchpad-pinch-speed_display-value' 74 | | 'volume-control-speed_display-value'; 75 | 76 | type Enum_Functions = { 77 | get_enum(key: K): T; 78 | set_enum(key: K, value: T): void; 79 | }; 80 | 81 | type SettingsEnumFunctions = Enum_Functions< 82 | | 'vertical-swipe-3-fingers-gesture' 83 | | 'horizontal-swipe-3-fingers-gesture' 84 | | 'vertical-swipe-4-fingers-gesture' 85 | | 'horizontal-swipe-4-fingers-gesture', 86 | SwipeGestureType 87 | > & 88 | Enum_Functions< 89 | 'pinch-3-finger-gesture' | 'pinch-4-finger-gesture', 90 | PinchGestureType 91 | > & 92 | Enum_Functions<'overview-navigation-states', OverviewNavigationState>; 93 | 94 | type Misc_Functions = { 95 | get_value(key: K): GLib.Variant; 96 | set_value(key: K, value: GLib.Variant): void; 97 | }; 98 | 99 | type SettingsMiscFunctions = Misc_Functions< 100 | 'forward-back-application-keyboard-shortcuts', 101 | 'a{s(ib)}' 102 | >; 103 | 104 | export type GioSettings = Omit< 105 | Gio.Settings, 106 | KeysThatStartsWith 107 | > & { 108 | get_boolean(key: BooleanSettingsKeys): boolean; 109 | get_int(key: IntegerSettingsKeys): number; 110 | get_double(key: DoubleSettingsKeys): number; 111 | set_double(key: DoubleSettingsKeys, value: number): void; 112 | } & SettingsEnumFunctions & 113 | SettingsMiscFunctions; 114 | -------------------------------------------------------------------------------- /extension/common/utils/logging.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * @param message 4 | */ 5 | export function printStack(message?: unknown) { 6 | const stack = new Error().stack; 7 | let prefix = ''; 8 | 9 | if (stack) { 10 | const lines = stack.split('\n')[1].split('@'); 11 | console.log(`[DEBUG]:: in function ${lines[0]} at ${lines[2]}`); 12 | prefix = '\t'; 13 | } 14 | 15 | if (message !== undefined) 16 | console.log(`${prefix}${JSON.stringify(message)}`); 17 | } 18 | -------------------------------------------------------------------------------- /extension/constants.ts: -------------------------------------------------------------------------------- 1 | // FIXME: ideally these values matches physical touchpad size. We can get the 2 | // correct values for gnome-shell specifically, since mutter uses libinput 3 | // directly, but GTK apps cannot get it, so use an arbitrary value so that 4 | // it's consistent with apps. 5 | export const TouchpadConstants = { 6 | DEFAULT_SWIPE_MULTIPLIER: 1, 7 | SWIPE_MULTIPLIER: 1, 8 | DEFAULT_PINCH_MULTIPLIER: 1, 9 | PINCH_MULTIPLIER: 1, 10 | DEFAULT_VOLUME_CONTROL_MULTIPLIER: 1, 11 | VOLUME_CONTROL_MULTIPLIER: 1, 12 | DRAG_THRESHOLD_DISTANCE: 16, 13 | TOUCHPAD_BASE_HEIGHT: 300, 14 | TOUCHPAD_BASE_WIDTH: 400, 15 | HOLD_SWIPE_DELAY_DURATION: 100, 16 | }; 17 | 18 | export const AltTabConstants = { 19 | DEFAULT_DELAY_DURATION: 100, 20 | DELAY_DURATION: 100, 21 | POPUP_SCROLL_TIME: 100, 22 | DUMMY_WIN_COUNT: 1, // so swiping to the end of touchpad is not needed for last window 23 | MIN_WIN_COUNT: 8, 24 | }; 25 | 26 | export const OverviewControlsState = { 27 | APP_GRID_P: -1, 28 | HIDDEN: 0, 29 | WINDOW_PICKER: 1, 30 | APP_GRID: 2, 31 | HIDDEN_N: 3, 32 | }; 33 | 34 | export const ExtSettings = { 35 | ALLOW_MINIMIZE_WINDOW: false, 36 | FOLLOW_NATURAL_SCROLL: true, 37 | APP_GESTURES: false, 38 | DEFAULT_OVERVIEW_GESTURE_DIRECTION: true, 39 | }; 40 | 41 | export const RELOAD_DELAY = 150; // reload extension delay in ms 42 | export const WIGET_SHOWING_DURATION = 100; // animation duration for showing widget 43 | -------------------------------------------------------------------------------- /extension/extension.ts: -------------------------------------------------------------------------------- 1 | import Gio from 'gi://Gio'; 2 | import GLib from 'gi://GLib'; 3 | import { 4 | Extension, 5 | ExtensionMetadata, 6 | } from 'resource:///org/gnome/shell/extensions/extension.js'; 7 | import { 8 | AllSettingsKeys, 9 | PinchGestureType, 10 | SwipeGestureType, 11 | } from './common/settings.js'; 12 | import * as Constants from './constants.js'; 13 | import {OverviewRoundTripGestureExtension} from './src/overviewRoundTrip.js'; 14 | import {GestureExtension} from './src/gestures.js'; 15 | import AltTabGestureExtension from './src/altTab.js'; 16 | import { 17 | ForwardBackGestureExtension, 18 | type AppForwardBackKeyBinds, 19 | } from './src/forwardBack.js'; 20 | import * as VKeyboard from './src/utils/keyboard.js'; 21 | import {SnapWindowExtension} from './src/snapWidnow.js'; 22 | import {ShowDesktopExtension} from './src/pinchGestures/showDesktop.js'; 23 | import {CloseWindowExtension} from './src/pinchGestures/closeWindow.js'; 24 | import {VolumeControlGestureExtension} from './src/volumeControl.js'; 25 | 26 | export default class TouchpadGestureCustomization extends Extension { 27 | private _extensions: ISubExtension[]; 28 | settings?: Gio.Settings; 29 | private _settingChangedId = 0; 30 | private _reloadWaitId = 0; 31 | private _addReloadDelayFor: AllSettingsKeys[]; 32 | 33 | constructor(metadata: ExtensionMetadata) { 34 | super(metadata); 35 | 36 | this._extensions = []; 37 | this._addReloadDelayFor = [ 38 | 'touchpad-speed-scale', 39 | 'alttab-delay', 40 | 'touchpad-pinch-speed', 41 | 'volume-control-speed', 42 | ]; 43 | } 44 | 45 | enable() { 46 | this.settings = this.getSettings(); 47 | this._settingChangedId = this.settings.connect( 48 | 'changed', 49 | this.reload.bind(this) 50 | ); 51 | this._enable(); 52 | } 53 | 54 | disable() { 55 | if (this.settings) this.settings.disconnect(this._settingChangedId); 56 | 57 | if (this._reloadWaitId !== 0) { 58 | GLib.source_remove(this._reloadWaitId); 59 | this._reloadWaitId = 0; 60 | } 61 | 62 | this.settings = undefined; 63 | 64 | this._disable(); 65 | } 66 | 67 | reload(_settings: never, key: AllSettingsKeys) { 68 | if (this._reloadWaitId !== 0) GLib.source_remove(this._reloadWaitId); 69 | 70 | this._reloadWaitId = GLib.timeout_add( 71 | GLib.PRIORITY_DEFAULT, 72 | this._addReloadDelayFor.includes(key) ? Constants.RELOAD_DELAY : 0, 73 | () => { 74 | this._disable(); 75 | this._enable(); 76 | this._reloadWaitId = 0; 77 | return GLib.SOURCE_REMOVE; 78 | } 79 | ); 80 | } 81 | 82 | _enable() { 83 | this._initializeSettings(); 84 | this._extensions = []; 85 | if (this.settings === undefined) return; 86 | 87 | const verticalSwipeToFingersMap = 88 | this._getVerticalSwipeGestureTypeAndFingers(); 89 | const horizontalSwipeToFingersMap = 90 | this._getHorizontalSwipeGestureTypeAndFingers(); 91 | 92 | /** 93 | * Overview navigation 94 | */ 95 | 96 | const verticalOverviewNavigationFingers = verticalSwipeToFingersMap.get( 97 | SwipeGestureType.OVERVIEW_NAVIGATION 98 | ); 99 | 100 | const horizontalOverviewNavigationFingers = 101 | horizontalSwipeToFingersMap.get( 102 | SwipeGestureType.OVERVIEW_NAVIGATION 103 | ); 104 | 105 | const overviewRoundTripGesterExtension = 106 | new OverviewRoundTripGestureExtension( 107 | this.settings.get_enum('overview-navigation-states') 108 | ); 109 | 110 | // By default, disable overview navigation when user doesn't assign any gestures 111 | overviewRoundTripGesterExtension.setVerticalSwipeTracker([]); 112 | 113 | // Enable vertical swipe for overview navigation 114 | if (verticalOverviewNavigationFingers?.length) { 115 | overviewRoundTripGesterExtension.setVerticalSwipeTracker( 116 | verticalOverviewNavigationFingers 117 | ); 118 | } 119 | 120 | // Enable horizontal swipe for overview navigation 121 | if (horizontalOverviewNavigationFingers?.length) { 122 | overviewRoundTripGesterExtension?.setHorizontalSwipeTracker( 123 | horizontalOverviewNavigationFingers 124 | ); 125 | } 126 | 127 | this._extensions.push(overviewRoundTripGesterExtension); 128 | 129 | /** 130 | * Workspace navigation 131 | */ 132 | 133 | // TODO: match workspace navigation control in overview mode and normal mode 134 | 135 | const verticalWorkspaceNavigationFingers = 136 | verticalSwipeToFingersMap.get(SwipeGestureType.WORKSPACE_SWITCHING); 137 | const horizontalWorkspaceNavigationFingers = 138 | horizontalSwipeToFingersMap.get( 139 | SwipeGestureType.WORKSPACE_SWITCHING 140 | ); 141 | 142 | const gestureExtension = new GestureExtension(); 143 | 144 | // Disable default workspace navigation using horizontal swipe 145 | gestureExtension.setHorizontalWorkspaceAnimationModifier([]); 146 | 147 | // Enable vertical swipe for workspace navigation 148 | if (verticalWorkspaceNavigationFingers?.length) 149 | gestureExtension.setVerticalWorkspceAnimationModifier( 150 | verticalWorkspaceNavigationFingers 151 | ); 152 | 153 | // Enable horizontal swipe for workspace navigation 154 | if (horizontalWorkspaceNavigationFingers?.length) 155 | gestureExtension.setHorizontalWorkspaceAnimationModifier( 156 | horizontalWorkspaceNavigationFingers 157 | ); 158 | 159 | this._extensions.push(gestureExtension); 160 | 161 | /** 162 | * Window switching (Alt + tab) 163 | */ 164 | 165 | const verticalWindowSwitchingFingers = verticalSwipeToFingersMap.get( 166 | SwipeGestureType.WINDOW_SWITCHING 167 | ); 168 | const horizontalWindowSwitchingFingers = 169 | horizontalSwipeToFingersMap.get(SwipeGestureType.WINDOW_SWITCHING); 170 | 171 | if ( 172 | verticalWindowSwitchingFingers?.length || 173 | horizontalWindowSwitchingFingers?.length 174 | ) { 175 | // TODO: update class name to WindowSwitchingGestureExtension 176 | const windowSwitchingGestureExtension = 177 | new AltTabGestureExtension(); 178 | 179 | // Enable vertical swipe for window switching 180 | if (verticalWindowSwitchingFingers?.length) 181 | windowSwitchingGestureExtension.setVerticalTouchpadSwipeTracker( 182 | verticalWindowSwitchingFingers 183 | ); 184 | 185 | // Enable horizontal swipe for window switching 186 | if (horizontalWindowSwitchingFingers?.length) 187 | windowSwitchingGestureExtension.setHorizontalTouchpadSwipeTracker( 188 | horizontalWindowSwitchingFingers 189 | ); 190 | 191 | this._extensions.push(windowSwitchingGestureExtension); 192 | } 193 | 194 | /** 195 | * Pinch Gestures 196 | */ 197 | 198 | const pinchToFingersMap = this._getPinchGestureTypeAndFingers(); 199 | 200 | // pinch to show desktop (not working) 201 | const showDesktopFingers = pinchToFingersMap.get( 202 | PinchGestureType.SHOW_DESKTOP 203 | ); 204 | 205 | if (showDesktopFingers?.length) { 206 | this._extensions.push(new ShowDesktopExtension(showDesktopFingers)); 207 | } 208 | 209 | // pinch to close window 210 | const closeWindowFingers = pinchToFingersMap.get( 211 | PinchGestureType.CLOSE_WINDOW 212 | ); 213 | if (closeWindowFingers?.length) 214 | this._extensions.push( 215 | new CloseWindowExtension( 216 | closeWindowFingers, 217 | PinchGestureType.CLOSE_WINDOW 218 | ) 219 | ); 220 | 221 | // pinch to close document 222 | const closeDocumentFingers = pinchToFingersMap.get( 223 | PinchGestureType.CLOSE_DOCUMENT 224 | ); 225 | if (closeDocumentFingers?.length) 226 | this._extensions.push( 227 | new CloseWindowExtension( 228 | closeDocumentFingers, 229 | PinchGestureType.CLOSE_DOCUMENT 230 | ) 231 | ); 232 | 233 | // TODO: consider having an option for 'hold and swipe gestures' that can either 234 | // be set to window tiling or app gesture (need to fix how to activate window tiling with 235 | // hold and swipe without being blocked by overview navigation) 236 | 237 | /** 238 | * Window Tiling/snapping & minimisation 239 | */ 240 | 241 | // TODO: when both vertical and horizontal swipe are not set to window manipulation 242 | // the switch for minimise window should be disbaled 243 | const verticalWindowManipulationFingers = verticalSwipeToFingersMap.get( 244 | SwipeGestureType.WINDOW_MANIPULATION 245 | ); 246 | 247 | if (verticalWindowManipulationFingers?.length) 248 | this._extensions.push( 249 | new SnapWindowExtension(verticalWindowManipulationFingers) 250 | ); 251 | 252 | /** 253 | * Volume Control 254 | */ 255 | 256 | const verticalVolumeControlFingers = verticalSwipeToFingersMap.get( 257 | SwipeGestureType.VOLUME_CONTROL 258 | ); 259 | const horizontalVolumeControlFingers = horizontalSwipeToFingersMap.get( 260 | SwipeGestureType.VOLUME_CONTROL 261 | ); 262 | 263 | if ( 264 | verticalVolumeControlFingers?.length || 265 | horizontalVolumeControlFingers?.length 266 | ) { 267 | const volumeControlGestureExtension = 268 | new VolumeControlGestureExtension(); 269 | 270 | // Enable vertical swipe for overview navigation 271 | if (verticalVolumeControlFingers?.length) { 272 | volumeControlGestureExtension.setVerticalSwipeTracker( 273 | verticalVolumeControlFingers 274 | ); 275 | } 276 | 277 | // Enable horizontal swipe for overview navigation 278 | if (horizontalVolumeControlFingers?.length) { 279 | volumeControlGestureExtension?.setHorizontalSwipeTracker( 280 | horizontalVolumeControlFingers 281 | ); 282 | } 283 | 284 | this._extensions.push(volumeControlGestureExtension); 285 | } 286 | 287 | /** 288 | * App Gestures 289 | */ 290 | if (this.settings.get_boolean('enable-forward-back-gesture')) { 291 | const appForwardBackKeyBinds: AppForwardBackKeyBinds = this.settings 292 | .get_value('forward-back-application-keyboard-shortcuts') 293 | .deepUnpack(); 294 | 295 | this._extensions.push( 296 | new ForwardBackGestureExtension( 297 | appForwardBackKeyBinds, 298 | this.metadata.dir.get_uri(), 299 | this.settings.get_boolean('enable-vertical-app-gesture') 300 | ) 301 | ); 302 | } 303 | 304 | this._extensions.forEach(extension => extension.apply?.()); 305 | } 306 | 307 | private _getVerticalSwipeGestureTypeAndFingers(): Map< 308 | SwipeGestureType, 309 | number[] 310 | > { 311 | if (!this.settings) return new Map(); 312 | 313 | const verticalSwipe3FingerGesture = this.settings.get_enum( 314 | 'vertical-swipe-3-fingers-gesture' 315 | ); 316 | const verticalSwipe4FingerGesture = this.settings.get_enum( 317 | 'vertical-swipe-4-fingers-gesture' 318 | ); 319 | 320 | const swipeGestureToFingersMap = new Map(); 321 | 322 | if (verticalSwipe3FingerGesture === verticalSwipe4FingerGesture) 323 | swipeGestureToFingersMap.set(verticalSwipe3FingerGesture, [3, 4]); 324 | else { 325 | swipeGestureToFingersMap.set(verticalSwipe3FingerGesture, [3]); 326 | swipeGestureToFingersMap.set(verticalSwipe4FingerGesture, [4]); 327 | } 328 | 329 | return swipeGestureToFingersMap; 330 | } 331 | 332 | private _getHorizontalSwipeGestureTypeAndFingers(): Map< 333 | SwipeGestureType, 334 | number[] 335 | > { 336 | if (!this.settings) return new Map(); 337 | 338 | const horizontalSwipe3FingerGesture = this.settings.get_enum( 339 | 'horizontal-swipe-3-fingers-gesture' 340 | ); 341 | const horizontalSwipe4FingerGesture = this.settings.get_enum( 342 | 'horizontal-swipe-4-fingers-gesture' 343 | ); 344 | 345 | const swipeGestureToFingersMap = new Map(); 346 | 347 | if (horizontalSwipe3FingerGesture === horizontalSwipe4FingerGesture) 348 | swipeGestureToFingersMap.set(horizontalSwipe3FingerGesture, [3, 4]); 349 | else { 350 | swipeGestureToFingersMap.set(horizontalSwipe3FingerGesture, [3]); 351 | swipeGestureToFingersMap.set(horizontalSwipe4FingerGesture, [4]); 352 | } 353 | 354 | return swipeGestureToFingersMap; 355 | } 356 | 357 | private _getPinchGestureTypeAndFingers(): Map { 358 | if (!this.settings) return new Map(); 359 | 360 | const pinch3FingerGesture = this.settings.get_enum( 361 | 'pinch-3-finger-gesture' 362 | ); 363 | const pinch4FingerGesture = this.settings.get_enum( 364 | 'pinch-4-finger-gesture' 365 | ); 366 | 367 | const gestureToFingersMap = new Map(); 368 | 369 | if (pinch3FingerGesture === pinch4FingerGesture) 370 | gestureToFingersMap.set(pinch3FingerGesture, [3, 4]); 371 | else { 372 | gestureToFingersMap.set(pinch3FingerGesture, [3]); 373 | gestureToFingersMap.set(pinch4FingerGesture, [4]); 374 | } 375 | 376 | return gestureToFingersMap; 377 | } 378 | 379 | _initializeSettings() { 380 | if (this.settings) { 381 | Constants.ExtSettings.ALLOW_MINIMIZE_WINDOW = 382 | this.settings.get_boolean('allow-minimize-window'); 383 | Constants.ExtSettings.FOLLOW_NATURAL_SCROLL = 384 | this.settings.get_boolean('follow-natural-scroll'); 385 | Constants.ExtSettings.DEFAULT_OVERVIEW_GESTURE_DIRECTION = 386 | this.settings.get_boolean('default-overview-gesture-direction'); 387 | Constants.ExtSettings.APP_GESTURES = this.settings.get_boolean( 388 | 'enable-forward-back-gesture' 389 | ); 390 | 391 | Constants.TouchpadConstants.SWIPE_MULTIPLIER = 392 | Constants.TouchpadConstants.DEFAULT_SWIPE_MULTIPLIER * 393 | this.settings.get_double('touchpad-speed-scale'); 394 | Constants.TouchpadConstants.PINCH_MULTIPLIER = 395 | Constants.TouchpadConstants.DEFAULT_PINCH_MULTIPLIER * 396 | this.settings.get_double('touchpad-pinch-speed'); 397 | Constants.TouchpadConstants.VOLUME_CONTROL_MULTIPLIER = 398 | Constants.TouchpadConstants.DEFAULT_VOLUME_CONTROL_MULTIPLIER * 399 | this.settings.get_double('volume-control-speed'); 400 | Constants.AltTabConstants.DELAY_DURATION = 401 | this.settings.get_int('alttab-delay'); 402 | Constants.TouchpadConstants.HOLD_SWIPE_DELAY_DURATION = 403 | this.settings.get_int('hold-swipe-delay-duration'); 404 | } 405 | } 406 | 407 | _disable() { 408 | VKeyboard.extensionCleanup(); 409 | this._extensions.reverse().forEach(extension => extension.destroy()); 410 | this._extensions = []; 411 | } 412 | } 413 | -------------------------------------------------------------------------------- /extension/prefs.ts: -------------------------------------------------------------------------------- 1 | import Adw from 'gi://Adw'; 2 | import {ExtensionPreferences} from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js'; 3 | import {buildPrefsWidget} from './prefs/pref.js'; 4 | 5 | export default class TouchpadGestureCustomizationPreferences extends ExtensionPreferences { 6 | fillPreferencesWindow(prefsWindow: Adw.PreferencesWindow) { 7 | const UIDirPath = this.metadata.dir.get_child('ui').get_path() ?? ''; 8 | const settings = this.getSettings(); 9 | buildPrefsWidget(prefsWindow, settings, UIDirPath); 10 | return Promise.resolve(); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /extension/prefs/appGestures.ts: -------------------------------------------------------------------------------- 1 | import Gtk from 'gi://Gtk'; 2 | import Gio from 'gi://Gio'; 3 | import GLib from 'gi://GLib'; 4 | import GObject from 'gi://GObject'; 5 | import Adw from 'gi://Adw'; 6 | import {ForwardBackKeyBinds, GioSettings} from '../common/settings.js'; 7 | import {printStack} from '../common/utils/logging.js'; 8 | import {type GtkBuilder} from './pref.js'; 9 | 10 | /** 11 | * return icon image for give app 12 | * @param app 13 | */ 14 | function getAppIconImage(app: Gio.AppInfo) { 15 | const iconName = app.get_icon()?.to_string() ?? 'icon-missing'; 16 | return new Gtk.Image({ 17 | gicon: Gio.icon_new_for_string(iconName), 18 | iconSize: Gtk.IconSize.LARGE, 19 | }); 20 | } 21 | 22 | /** 23 | * Returns marked escaped text or empty string if text is nullable 24 | * @param text 25 | */ 26 | function markup_escape_text(text?: string | null) { 27 | text = text ?? ''; 28 | 29 | try { 30 | return GLib.markup_escape_text(text, -1); 31 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 32 | } catch (e: any) { 33 | // TODO: see what exactly is error and fix it 34 | // probably errors in different language or app name 35 | printStack( 36 | `Error: '${e?.message ?? e}' while escaping app name for app(${text}))` 37 | ); 38 | return text; 39 | } 40 | } 41 | 42 | /** 43 | * Dialog window used for selecting application from given list of apps 44 | * Emits `app-selected` signal with application id 45 | */ 46 | const AppChooserDialog = GObject.registerClass( 47 | { 48 | Properties: {}, 49 | Signals: {'app-selected': {param_types: [GObject.TYPE_STRING]}}, 50 | }, 51 | class GIE_AppChooserDialog extends Adw.PreferencesWindow { 52 | private _group: Adw.PreferencesGroup; 53 | 54 | /** 55 | * @param apps list of apps to display in dialog 56 | * @param parent parent window, dialog will be transient for parent 57 | */ 58 | constructor(apps: Gio.AppInfo[], parent: Adw.Window) { 59 | super({ 60 | modal: true, 61 | transientFor: parent, 62 | destroyWithParent: false, 63 | title: 'Select application', 64 | }); 65 | 66 | this.set_default_size( 67 | 0.7 * parent.defaultWidth, 68 | 0.7 * parent.defaultHeight 69 | ); 70 | 71 | this._group = new Adw.PreferencesGroup(); 72 | const page = new Adw.PreferencesPage(); 73 | page.add(this._group); 74 | this.add(page); 75 | 76 | apps.forEach(app => this._addAppRow(app)); 77 | } 78 | 79 | /** 80 | * for given app add row to selectable list 81 | * @param app 82 | */ 83 | private _addAppRow(app: Gio.AppInfo) { 84 | const row = new Adw.ActionRow({ 85 | title: markup_escape_text(app.get_display_name()), 86 | subtitle: markup_escape_text(app.get_description()), 87 | activatable: true, 88 | }); 89 | 90 | row.add_prefix(getAppIconImage(app)); 91 | this._group.add(row); 92 | 93 | row.connect('activated', () => { 94 | this.emit('app-selected', app.get_id()); 95 | this.close(); 96 | }); 97 | } 98 | } 99 | ); 100 | 101 | /** type definition for gesture setting(keybind and reverse flag) for app */ 102 | declare type AppGestureSettings = [ForwardBackKeyBinds, boolean]; 103 | 104 | /** 105 | * Class to create row for application in list to display gesture settings of app 106 | * Emits 'value-updated' when any of settings changes 107 | * Emits 'remove-request' when remove button is clicked 108 | */ 109 | const AppGestureSettingsRow = GObject.registerClass( 110 | { 111 | Properties: {}, 112 | Signals: { 113 | 'value-updated': { 114 | param_types: [GObject.TYPE_UINT, GObject.TYPE_BOOLEAN], 115 | }, 116 | 'remove-request': {}, 117 | }, 118 | }, 119 | class GIE_AppGestureSettingsRow extends Adw.ExpanderRow { 120 | private _keyBindCombo: Adw.ComboRow; 121 | private _reverseButton: Gtk.Switch; 122 | 123 | /** 124 | * @param app 125 | * @param appGestureSettings value of current settings for app 126 | * @param model list of choices of keybings for setting 127 | */ 128 | constructor( 129 | app: Gio.AppInfo, 130 | appGestureSettings: AppGestureSettings, 131 | model: Gio.ListModel 132 | ) { 133 | super({title: markup_escape_text(app.get_display_name())}); 134 | this.add_prefix(getAppIconImage(app)); 135 | 136 | const [keyBind, reverse] = appGestureSettings; 137 | 138 | // keybinding combo row 139 | this._keyBindCombo = new Adw.ComboRow({ 140 | title: 'Keybinding', 141 | subtitle: 142 | 'Keyboard shortcut to emit after gesture is completed', 143 | model, 144 | }); 145 | 146 | this._keyBindCombo.set_selected(keyBind); 147 | this.add_row(this._keyBindCombo); 148 | 149 | // reverse switch row 150 | this._reverseButton = new Gtk.Switch({ 151 | active: reverse, 152 | valign: Gtk.Align.CENTER, 153 | }); 154 | 155 | let actionRow = new Adw.ActionRow({ 156 | title: 'Reverse gesture direction', 157 | }); 158 | actionRow.add_suffix(this._reverseButton); 159 | this.add_row(actionRow); 160 | 161 | // remove setting row 162 | const removeButton = new Gtk.Button({ 163 | label: 'Remove...', 164 | valign: Gtk.Align.CENTER, 165 | halign: Gtk.Align.END, 166 | cssClasses: ['raised'], 167 | }); 168 | 169 | actionRow = new Adw.ActionRow(); 170 | actionRow.add_suffix(removeButton); 171 | this.add_row(actionRow); 172 | 173 | // remove request signal emitted when remove button is clicked 174 | removeButton.connect('clicked', () => this.emit('remove-request')); 175 | this._keyBindCombo.connect( 176 | 'notify::selected', 177 | this._onValueUpdated.bind(this) 178 | ); 179 | this._reverseButton.connect( 180 | 'notify::active', 181 | this._onValueUpdated.bind(this) 182 | ); 183 | } 184 | 185 | /** function called internally whenever some setting is changed, emits external signal */ 186 | private _onValueUpdated() { 187 | this.emit( 188 | 'value-updated', 189 | this._keyBindCombo.selected, 190 | this._reverseButton.active 191 | ); 192 | } 193 | } 194 | ); 195 | 196 | /** 197 | * Class to display list of applications and their gesture settings 198 | */ 199 | const AppKeybindingGesturePrefsGroup = GObject.registerClass( 200 | class GIE_AppKeybindingGesturePrefsGroup extends Adw.PreferencesGroup { 201 | private _settings: GioSettings; 202 | private _prefsWindow: Adw.PreferencesWindow; 203 | private _appRows: Map; 204 | private _cachedSettings: Record; 205 | private _addAppButtonRow: Adw.PreferencesRow; 206 | private _appGestureModel: Gtk.StringList; 207 | 208 | /** 209 | * @param prefsWindow parent preferences window 210 | * @param settings extension settings object 211 | */ 212 | constructor(prefsWindow: Adw.PreferencesWindow, settings: GioSettings) { 213 | super({ 214 | title: 'Enable application specific gestures', 215 | description: 216 | 'Hold and then swipe horizontally to activate the gesture', 217 | }); 218 | 219 | this._prefsWindow = prefsWindow; 220 | this._settings = settings; 221 | this._appRows = new Map(); 222 | 223 | this._cachedSettings = this._settings 224 | .get_value('forward-back-application-keyboard-shortcuts') 225 | .deepUnpack(); 226 | this._appGestureModel = this._getAppGestureModelForComboBox(); 227 | 228 | // build ui widgets 229 | this.add( 230 | new Adw.PreferencesRow({ 231 | child: new Gtk.Label({ 232 | label: 'Applications not listed here will have default settings', 233 | halign: Gtk.Align.CENTER, 234 | hexpand: true, 235 | }), 236 | cssClasses: [ 237 | 'custom-information-label-row', 238 | 'custom-smaller-card', 239 | ], 240 | }) 241 | ); 242 | 243 | this._addAppButtonRow = this._buildAddAppButtonRow(); 244 | this.add(this._addAppButtonRow); 245 | 246 | Object.keys(this._cachedSettings) 247 | .sort() 248 | .reverse() 249 | .forEach(appId => this._addAppGestureRow(appId)); 250 | 251 | // bind switch to setting value 252 | const toggleSwitch = new Gtk.Switch({valign: Gtk.Align.CENTER}); 253 | this._settings.bind( 254 | 'enable-forward-back-gesture', 255 | toggleSwitch, 256 | 'active', 257 | Gio.SettingsBindFlags.DEFAULT 258 | ); 259 | this.set_header_suffix(toggleSwitch); 260 | } 261 | 262 | /** 263 | * Handler function, called when add button is clicked 264 | * Displays dialog to select application to add 265 | */ 266 | private _onAddAppButtonClicked() { 267 | // find list of new apps that can be selected 268 | const allApps = Gio.app_info_get_all(); 269 | const selectableApps = allApps 270 | .filter(app => { 271 | const appId = app.get_id(); 272 | return ( 273 | app.should_show() && appId && !this._appRows.has(appId) 274 | ); 275 | }) 276 | .sort((a, b) => a.get_id()!.localeCompare(b.get_id()!)); 277 | 278 | const appChooserDialog = new AppChooserDialog( 279 | selectableApps, 280 | this._prefsWindow 281 | ); 282 | 283 | appChooserDialog.connect( 284 | 'app-selected', 285 | (_source: never, appId: string) => this._addAppGestureRow(appId) 286 | ); 287 | appChooserDialog.present(); 288 | } 289 | 290 | /** 291 | * @returns row for add app button 292 | */ 293 | private _buildAddAppButtonRow() { 294 | const addButton = new Gtk.Button({ 295 | iconName: 'list-add-symbolic', 296 | cssName: 'card', 297 | cssClasses: ['custom-smaller-card'], 298 | }); 299 | 300 | const addButtonRow = new Adw.PreferencesRow({child: addButton}); 301 | addButton.connect( 302 | 'clicked', 303 | this._onAddAppButtonClicked.bind(this) 304 | ); 305 | 306 | return addButtonRow; 307 | } 308 | 309 | /** 310 | * Adds application specific gesture settings row for given app id 311 | * Does nothing if app doesn't exist 312 | * @param appId 313 | */ 314 | private _addAppGestureRow(appId: string) { 315 | const app = Gio.DesktopAppInfo.new(appId); 316 | if (!app) return; 317 | 318 | const appRow = new AppGestureSettingsRow( 319 | app, 320 | this._getAppGestureSetting(appId), // this function updates extension settings 321 | this._appGestureModel 322 | ); 323 | 324 | this._appRows.set(appId, appRow); 325 | this.add(appRow); 326 | 327 | // callbacks for setting updates and remove request 328 | appRow.connect('remove-request', () => 329 | this._requestRemoveAppGestureRow(appId) 330 | ); 331 | 332 | appRow.connect( 333 | 'value-updated', 334 | ( 335 | _source: never, 336 | keyBind: ForwardBackKeyBinds, 337 | reverse: boolean 338 | ) => { 339 | this._setAppGestureSetting(appId, [keyBind, reverse]); 340 | } 341 | ); 342 | 343 | // re-add add-appbutton at the end 344 | this.remove(this._addAppButtonRow); 345 | this.add(this._addAppButtonRow); 346 | } 347 | 348 | /** 349 | * Removes application specific gesture settings row for given app 350 | * Does nothing if row for app was not added 351 | * Updates extension settings 352 | * @param appId 353 | */ 354 | private _removeAppGestureRow(appId: string) { 355 | const appRow = this._appRows.get(appId); 356 | if (!appRow) return; 357 | 358 | this.remove(appRow); 359 | this._appRows.delete(appId); 360 | delete this._cachedSettings[appId]; 361 | this._updateExtensionSettings(); 362 | } 363 | 364 | /** 365 | * Signal handler called when removal of app gesture settings is requested 366 | * Displays confirmation dialog and removes app row if confirmed 367 | * @param appId 368 | */ 369 | private _requestRemoveAppGestureRow(appId: string) { 370 | const app = Gio.DesktopAppInfo.new(appId); 371 | 372 | const dialog = new Gtk.MessageDialog({ 373 | transient_for: this._prefsWindow, 374 | modal: true, 375 | text: `Remove gesture setting for ${app.get_display_name()}?`, 376 | }); 377 | 378 | dialog.add_button('Cancel', Gtk.ResponseType.CANCEL); 379 | dialog 380 | .add_button('Remove', Gtk.ResponseType.ACCEPT) 381 | .get_style_context() 382 | .add_class('destructive-action'); 383 | 384 | dialog.connect('response', (_dlg, response) => { 385 | if (response === Gtk.ResponseType.ACCEPT) 386 | this._removeAppGestureRow(appId); 387 | 388 | dialog.destroy(); 389 | }); 390 | 391 | dialog.present(); 392 | } 393 | 394 | /** 395 | * Returns application specific gesture setting 396 | * if setting is not set, returns default value and saves extension settings 397 | * @param appId 398 | */ 399 | private _getAppGestureSetting(appId: string): AppGestureSettings { 400 | let val = this._cachedSettings[appId]; 401 | 402 | if (!val) { 403 | // this is case when new app was selected for gesture 404 | val = [ForwardBackKeyBinds.Default, false]; 405 | this._setAppGestureSetting(appId, val); 406 | } 407 | 408 | return val; 409 | } 410 | 411 | /** 412 | * Saves application specific gesture setting into extension settings 413 | * @param appId 414 | * @param appGestureSettings 415 | */ 416 | private _setAppGestureSetting( 417 | appId: string, 418 | appGestureSettings: AppGestureSettings 419 | ) { 420 | this._cachedSettings[appId] = appGestureSettings; 421 | this._updateExtensionSettings(); 422 | } 423 | 424 | /** Updates extension settings */ 425 | private _updateExtensionSettings() { 426 | const glibVariant = new GLib.Variant( 427 | 'a{s(ib)}', 428 | this._cachedSettings 429 | ); 430 | this._settings.set_value( 431 | 'forward-back-application-keyboard-shortcuts', 432 | glibVariant 433 | ); 434 | } 435 | 436 | /** Returns model which contains all possible choices for keybinding setting for app-gesture */ 437 | private _getAppGestureModelForComboBox() { 438 | const appGestureModel = new Gtk.StringList(); 439 | Object.values(ForwardBackKeyBinds).forEach(val => { 440 | if (typeof val !== 'number') return; 441 | appGestureModel.append(ForwardBackKeyBinds[val]); 442 | }); 443 | 444 | return appGestureModel; 445 | } 446 | } 447 | ); 448 | 449 | /** 450 | * @param prefsWindow 451 | * @param settings 452 | * @returns preference page for application gestures 453 | */ 454 | export function getAppKeybindingGesturePrefsPage( 455 | prefsWindow: Adw.PreferencesWindow, 456 | settings: GioSettings, 457 | builder: GtkBuilder 458 | ) { 459 | const page = new Adw.PreferencesPage({ 460 | title: 'App Gestures', 461 | iconName: 'org.gnome.Settings-applications-symbolic', 462 | }); 463 | 464 | page.add(new AppKeybindingGesturePrefsGroup(prefsWindow, settings)); 465 | 466 | return page; 467 | } 468 | -------------------------------------------------------------------------------- /extension/prefs/pref.ts: -------------------------------------------------------------------------------- 1 | import Gtk from 'gi://Gtk'; 2 | import Adw from 'gi://Adw'; 3 | import Gio from 'gi://Gio'; 4 | import Gdk from 'gi://Gdk'; 5 | import GObject from 'gi://GObject'; 6 | import { 7 | AllUIObjectKeys, 8 | BooleanSettingsKeys, 9 | DoubleSettingsKeys, 10 | EnumSettingsKeys, 11 | GioSettings, 12 | IntegerSettingsKeys, 13 | } from '../common/settings.js'; 14 | import {getAppKeybindingGesturePrefsPage} from './appGestures.js'; 15 | 16 | export type GtkBuilder = Omit & { 17 | get_object(name: AllUIObjectKeys): T; 18 | }; 19 | 20 | /** 21 | * Bind value of setting to {@link Gtk.SpinButton} 22 | * @param key key of setting and id of {@link Gtk.SpinButton} object in builder 23 | * @param settings 24 | * @param builder 25 | */ 26 | function bind_int_value( 27 | key: IntegerSettingsKeys, 28 | settings: GioSettings, 29 | builder: GtkBuilder 30 | ) { 31 | const button = builder.get_object(key); 32 | settings.bind(key, button, 'value', Gio.SettingsBindFlags.DEFAULT); 33 | } 34 | 35 | /** 36 | * Bind value of setting to {@link Gtk.Switch} 37 | * @param key key of setting and id of {@link Gtk.Switch} object in builder 38 | * @param settings 39 | * @param builder 40 | * @param flags flag used when binding setting's key to switch's {@link Gtk.Switch.active} status 41 | */ 42 | function bind_boolean_value( 43 | key: BooleanSettingsKeys, 44 | settings: GioSettings, 45 | builder: GtkBuilder, 46 | flags?: Gio.SettingsBindFlags 47 | ) { 48 | const button = builder.get_object(key); 49 | settings.bind( 50 | key, 51 | button, 52 | 'active', 53 | flags ?? Gio.SettingsBindFlags.DEFAULT 54 | ); 55 | } 56 | 57 | /** 58 | * Bind value of setting to {@link Adw.ComboRow} 59 | * @param key key of settings and id of {@link Adw.ComboRow} object in builder 60 | * @param settings 61 | * @param builder 62 | */ 63 | function bind_combo_box( 64 | key: EnumSettingsKeys, 65 | settings: GioSettings, 66 | builder: GtkBuilder 67 | ) { 68 | const comboRow = builder.get_object(key); 69 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 70 | const enum_key = key as any; 71 | comboRow.set_selected(settings.get_enum(enum_key)); 72 | comboRow.connect('notify::selected', () => { 73 | settings.set_enum(enum_key, comboRow.selected); 74 | }); 75 | } 76 | 77 | /** 78 | * Display value of `key` in log scale. 79 | * @param key key of setting and id of {@link Gtk.Scale} object in builder 80 | * @param label_key 81 | * @param settings 82 | * @param builder 83 | */ 84 | function display_in_log_scale( 85 | key: DoubleSettingsKeys, 86 | label_key: AllUIObjectKeys, 87 | settings: GioSettings, 88 | builder: GtkBuilder 89 | ) { 90 | const scale = builder.get_object(key); 91 | const label = builder.get_object(label_key); 92 | 93 | // display value in log scale 94 | scale.connect('value-changed', () => { 95 | const labelValue = Math.exp( 96 | scale.adjustment.value / Math.LOG2E 97 | ).toFixed(2); 98 | label.set_text(labelValue); 99 | settings.set_double(key, parseFloat(labelValue)); 100 | }); 101 | 102 | const initialValue = Math.log2(settings.get_double(key)); 103 | scale.set_value(initialValue); 104 | } 105 | 106 | /** 107 | * Binds preference widgets and settings keys 108 | * @param builder builder object for preference widgets 109 | * @param settings setting object of extension 110 | */ 111 | function bindPrefsSettings(builder: GtkBuilder, settings: Gio.Settings) { 112 | display_in_log_scale( 113 | 'touchpad-speed-scale', 114 | 'touchpad-speed-scale_display-value', 115 | settings, 116 | builder 117 | ); 118 | display_in_log_scale( 119 | 'touchpad-pinch-speed', 120 | 'touchpad-pinch-speed_display-value', 121 | settings, 122 | builder 123 | ); 124 | display_in_log_scale( 125 | 'volume-control-speed', 126 | 'volume-control-speed_display-value', 127 | settings, 128 | builder 129 | ); 130 | 131 | bind_int_value('alttab-delay', settings, builder); 132 | bind_int_value('hold-swipe-delay-duration', settings, builder); 133 | 134 | bind_boolean_value('follow-natural-scroll', settings, builder); 135 | bind_boolean_value( 136 | 'default-overview-gesture-direction', 137 | settings, 138 | builder, 139 | Gio.SettingsBindFlags.INVERT_BOOLEAN 140 | ); 141 | bind_boolean_value('enable-vertical-app-gesture', settings, builder); 142 | 143 | bind_boolean_value('allow-minimize-window', settings, builder); 144 | 145 | bind_combo_box('vertical-swipe-3-fingers-gesture', settings, builder); 146 | bind_combo_box('horizontal-swipe-3-fingers-gesture', settings, builder); 147 | bind_combo_box('vertical-swipe-4-fingers-gesture', settings, builder); 148 | bind_combo_box('horizontal-swipe-4-fingers-gesture', settings, builder); 149 | 150 | bind_combo_box('pinch-3-finger-gesture', settings, builder); 151 | bind_combo_box('pinch-4-finger-gesture', settings, builder); 152 | bind_combo_box('overview-navigation-states', settings, builder); 153 | } 154 | 155 | /** 156 | * 157 | * @param styleManager 158 | * @param uiDir 159 | */ 160 | function loadCssProvider(styleManager: Adw.StyleManager, uiDir: string) { 161 | const cssProvider = new Gtk.CssProvider(); 162 | cssProvider.load_from_path( 163 | `${uiDir}/${styleManager.dark ? 'style-dark' : 'style'}.css` 164 | ); 165 | const gtkDefaultDisplay = Gdk.Display.get_default(); 166 | 167 | if (gtkDefaultDisplay) { 168 | Gtk.StyleContext.add_provider_for_display( 169 | gtkDefaultDisplay, 170 | cssProvider, 171 | Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION 172 | ); 173 | } 174 | } 175 | 176 | /** 177 | * 178 | * @param prefsWindow 179 | * @param settings 180 | * @param uiDir 181 | */ 182 | export function buildPrefsWidget( 183 | prefsWindow: Adw.PreferencesWindow, 184 | settings: Gio.Settings, 185 | uiDir: string 186 | ) { 187 | prefsWindow.set_search_enabled(true); 188 | 189 | const styleManager = Adw.StyleManager.get_default(); 190 | styleManager.connect('notify::dark', () => 191 | loadCssProvider(styleManager, uiDir) 192 | ); 193 | loadCssProvider(styleManager, uiDir); 194 | 195 | const builder = new Gtk.Builder() as GtkBuilder; 196 | builder.add_from_file(`${uiDir}/gestures.ui`); 197 | builder.add_from_file(`${uiDir}/customizations.ui`); 198 | 199 | // bind to settings 200 | bindPrefsSettings(builder, settings); 201 | 202 | // pinch gesture page 203 | prefsWindow.add(builder.get_object('gestures_page')); 204 | 205 | // application specific gestures 206 | const app_gesture_page = getAppKeybindingGesturePrefsPage( 207 | prefsWindow, 208 | settings, 209 | builder 210 | ); 211 | prefsWindow.add(app_gesture_page); 212 | 213 | // customize page 214 | prefsWindow.add( 215 | builder.get_object('customizations_page') 216 | ); 217 | } 218 | -------------------------------------------------------------------------------- /extension/schemas/org.gnome.shell.extensions.touchpad-gesture-customization.gschema.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 1.0 33 | 34 | 35 | 1.0 36 | 37 | 38 | 1.0 39 | 40 | 41 | 150 42 | 43 | 44 | 10 45 | 46 | 47 | false 48 | If true, when swipe down on non-maximized window minimizes it 49 | 50 | 51 | true 52 | Whether to follow natural scroll for workspace switching 53 | 54 | 55 | true 56 | Default direction for overview navigation 57 | 58 | 59 | false 60 | Enable forward/back keybinding gesture 61 | 62 | 63 | false 64 | Enable vertical swipe for app gesture 65 | 66 | 67 | 'CLOSE_DOCUMENT' 68 | Gesture for 3 finger pinch 69 | 70 | 71 | 'CLOSE_WINDOW' 72 | Gesture for 4 finger pinch 73 | 74 | 75 | 'OVERVIEW_NAVIGATION' 76 | Gesture action for 3-fingers vertical swipe 77 | 78 | 79 | 'WINDOW_SWITCHING' 80 | Gesture action for 3-fingers horizontal swipe 81 | 82 | 83 | 'WINDOW_MANIPULATION' 84 | Gesture action for 4-fingers vertical swipe 85 | 86 | 87 | 'WORKSPACE_SWITCHING' 88 | Gesture action for 4-fingers horizontal swipe 89 | 90 | 91 | 'WINDOW_PICKER_ONLY' 92 | Customization for overview and app-grid navigation using vertical swipe 93 | 94 | 103 | 104 | 105 | { 106 | 'org.mozilla.firefox.desktop': (5, false), 107 | 'org.chromium.Chromium.desktop': (5, false), 108 | 'microsoft-edge.desktop': (5, false), 109 | 'google-chrome.desktop': (5, false), 110 | 'brave-browser.desktop': (5, false), 111 | 'org.gnome.gThumb.desktop': (2, false), 112 | 'org.gnome.eog.desktop': (3, false), 113 | 'org.gnome.Photos.desktop': (3, false), 114 | 'org.gnome.loupe.desktop': (3, false), 115 | 'shotwell.desktop': (3, false), 116 | 'com.spotify.Client.desktop': (4, false), 117 | 'code.desktop': (5, false), 118 | 'code-insiders.desktop': (5, false), 119 | 'codium.desktop': (5, false), 120 | 'org.gnome.Terminal.desktop': (5, false), 121 | 'org.gnome.TextEditor.desktop': (5, false), 122 | 'org.gnome.Rhythmbox3.desktop': (4, false) 123 | } 124 | 125 | Application keyboard shortcuts for forward-back gesture 126 | 127 | 128 | -------------------------------------------------------------------------------- /extension/src/altTab.ts: -------------------------------------------------------------------------------- 1 | import Clutter from 'gi://Clutter'; 2 | import GLib from 'gi://GLib'; 3 | import Shell from 'gi://Shell'; 4 | import St from 'gi://St'; 5 | import * as Main from 'resource:///org/gnome/shell/ui/main.js'; 6 | import {WindowSwitcherPopup} from 'resource:///org/gnome/shell/ui/altTab.js'; 7 | import {AltTabConstants, ExtSettings} from '../constants.js'; 8 | import {TouchpadSwipeGesture} from './swipeTracker.js'; 9 | 10 | let dummyWinCount = AltTabConstants.DUMMY_WIN_COUNT; 11 | 12 | /** 13 | * 14 | * @param progress 15 | * @param nelement 16 | */ 17 | function getIndexForProgress(progress: number, nelement: number): number { 18 | let index = Math.floor(progress * (nelement + 2 * dummyWinCount)); 19 | index = index - dummyWinCount; 20 | return Math.clamp(index, 0, nelement - 1); 21 | } 22 | 23 | /** 24 | * index -> index + AltTabConstants.DUMMY_WIN_COUNT 25 | * @param index 26 | * @param nelement 27 | */ 28 | function getAvgProgressForIndex(index: number, nelement: number): number { 29 | index = index + dummyWinCount; 30 | const progress = (index + 0.5) / (nelement + 2 * dummyWinCount); 31 | return progress; 32 | } 33 | 34 | enum AltTabExtState { 35 | DISABLED = 0, 36 | DEFAULT = 1, 37 | ALTTABDELAY = 2, 38 | ALTTAB = 3, 39 | } 40 | 41 | export default class AltTabGestureExtension implements ISubExtension { 42 | private _verticalTouchpadSwipeTracker?: typeof TouchpadSwipeGesture.prototype; 43 | private _horizontalTouchpadSwipeTracker?: typeof TouchpadSwipeGesture.prototype; 44 | private _verticalConnectHandlers?: number[]; 45 | private _horizontalConnectHandlers?: number[]; 46 | private _adjustment: St.Adjustment; 47 | private _switcher?: typeof WindowSwitcherPopup.prototype; 48 | private _extState = AltTabExtState.DISABLED; 49 | private _progress = 0; 50 | private _altTabTimeoutId = 0; 51 | 52 | constructor() { 53 | this._adjustment = new St.Adjustment({ 54 | value: 0, 55 | lower: 0, 56 | upper: 1, 57 | }); 58 | } 59 | 60 | setVerticalTouchpadSwipeTracker(nfingers: number[]) { 61 | // disconnect and destroy vertical touchpad swipe tracker if exist 62 | this._verticalConnectHandlers?.forEach(handle => 63 | this._verticalTouchpadSwipeTracker?.disconnect(handle) 64 | ); 65 | 66 | this._verticalTouchpadSwipeTracker?.destroy(); 67 | 68 | this._verticalTouchpadSwipeTracker = new TouchpadSwipeGesture( 69 | nfingers, 70 | Shell.ActionMode.ALL, 71 | Clutter.Orientation.VERTICAL, 72 | false, 73 | this._checkAllowedGestureforVerticalSwipe.bind(this) 74 | ); 75 | 76 | this._verticalConnectHandlers = [ 77 | this._verticalTouchpadSwipeTracker.connect( 78 | 'begin', 79 | this._gestureBegin.bind(this) 80 | ), 81 | this._verticalTouchpadSwipeTracker.connect( 82 | 'update', 83 | this._gestureUpdate.bind(this) 84 | ), 85 | this._verticalTouchpadSwipeTracker.connect( 86 | 'end', 87 | this._gestureEnd.bind(this) 88 | ), 89 | ]; 90 | } 91 | 92 | setHorizontalTouchpadSwipeTracker(nfingers: number[]) { 93 | // disconnect and destroy horizontal touchpad swipe tracker if exist 94 | this._horizontalConnectHandlers?.forEach(handle => 95 | this._horizontalTouchpadSwipeTracker?.disconnect(handle) 96 | ); 97 | 98 | this._horizontalTouchpadSwipeTracker?.destroy(); 99 | 100 | this._horizontalTouchpadSwipeTracker = new TouchpadSwipeGesture( 101 | nfingers, 102 | Shell.ActionMode.ALL, 103 | Clutter.Orientation.HORIZONTAL, 104 | false, 105 | this._checkAllowedGestureforHorizontalSwipe.bind(this) 106 | ); 107 | 108 | this._horizontalConnectHandlers = [ 109 | this._horizontalTouchpadSwipeTracker.connect( 110 | 'begin', 111 | this._gestureBegin.bind(this) 112 | ), 113 | this._horizontalTouchpadSwipeTracker.connect( 114 | 'update', 115 | this._gestureUpdate.bind(this) 116 | ), 117 | this._horizontalTouchpadSwipeTracker.connect( 118 | 'end', 119 | this._gestureEnd.bind(this) 120 | ), 121 | ]; 122 | } 123 | 124 | _checkAllowedGestureforVerticalSwipe(): boolean { 125 | return ( 126 | this._extState <= AltTabExtState.DEFAULT && 127 | Main.actionMode === Shell.ActionMode.NORMAL && 128 | !( 129 | ExtSettings.APP_GESTURES && 130 | this._verticalTouchpadSwipeTracker?.isItHoldAndSwipeGesture() 131 | ) 132 | ); 133 | } 134 | 135 | _checkAllowedGestureforHorizontalSwipe(): boolean { 136 | return ( 137 | this._extState <= AltTabExtState.DEFAULT && 138 | Main.actionMode === Shell.ActionMode.NORMAL && 139 | !( 140 | ExtSettings.APP_GESTURES && 141 | this._horizontalTouchpadSwipeTracker?.isItHoldAndSwipeGesture() 142 | ) 143 | ); 144 | } 145 | 146 | apply(): void { 147 | this._adjustment?.connect( 148 | 'notify::value', 149 | this._onUpdateAdjustmentValue.bind(this) 150 | ); 151 | 152 | this._extState = AltTabExtState.DEFAULT; 153 | } 154 | 155 | destroy(): void { 156 | // disconnect and destroy vertical touchpad swipe tracker 157 | this._verticalConnectHandlers?.forEach(handle => 158 | this._verticalTouchpadSwipeTracker?.disconnect(handle) 159 | ); 160 | 161 | this._verticalTouchpadSwipeTracker?.destroy(); 162 | this._verticalTouchpadSwipeTracker = undefined; 163 | this._verticalConnectHandlers = undefined; 164 | 165 | // disconnect and destroy horizontal touchpad swipe tracker 166 | this._horizontalConnectHandlers?.forEach(handle => 167 | this._horizontalTouchpadSwipeTracker?.disconnect(handle) 168 | ); 169 | 170 | this._horizontalTouchpadSwipeTracker?.destroy(); 171 | this._horizontalTouchpadSwipeTracker = undefined; 172 | this._horizontalConnectHandlers = undefined; 173 | 174 | this._extState = AltTabExtState.DISABLED; 175 | 176 | if (this._altTabTimeoutId) { 177 | GLib.source_remove(this._altTabTimeoutId); 178 | this._altTabTimeoutId = 0; 179 | } 180 | 181 | if (this._switcher) { 182 | this._switcher.destroy(); 183 | this._switcher = undefined; 184 | } 185 | } 186 | 187 | _onUpdateAdjustmentValue(): void { 188 | if (this._extState === AltTabExtState.ALTTAB && this._switcher) { 189 | const nelement = this._switcher._items.length; 190 | 191 | if (nelement > 1) { 192 | const n = getIndexForProgress(this._adjustment.value, nelement); 193 | this._switcher._select(n); 194 | const adjustment = 195 | this._switcher._switcherList._scrollView.hscroll.adjustment; 196 | const transition = adjustment.get_transition('value'); 197 | 198 | if (transition) { 199 | transition.advance(AltTabConstants.POPUP_SCROLL_TIME); 200 | } 201 | } 202 | } 203 | } 204 | 205 | _gestureBegin(): void { 206 | this._progress = 0; 207 | 208 | if (this._extState === AltTabExtState.DEFAULT) { 209 | this._switcher = new WindowSwitcherPopup(); 210 | this._switcher._switcherList.add_style_class_name( 211 | 'gie-alttab-quick-transition' 212 | ); 213 | this._switcher.connect('destroy', () => { 214 | this._switcher = undefined; 215 | this._reset(); 216 | }); 217 | 218 | // remove timeout entirely 219 | this._switcher._resetNoModsTimeout = function () { 220 | if (this._noModsTimeoutId) { 221 | GLib.source_remove(this._noModsTimeoutId); 222 | this._noModsTimeoutId = 0; 223 | } 224 | }; 225 | 226 | const nelement = this._switcher._items.length; 227 | 228 | if (nelement > 0) { 229 | this._switcher.show(false, 'switch-windows', 0); 230 | this._switcher._popModal(); 231 | 232 | if (this._switcher._initialDelayTimeoutId) { 233 | GLib.source_remove(this._switcher._initialDelayTimeoutId); 234 | this._switcher._initialDelayTimeoutId = 0; 235 | } 236 | 237 | const leftOver = AltTabConstants.MIN_WIN_COUNT - nelement; 238 | 239 | if (leftOver > 0) { 240 | dummyWinCount = Math.max( 241 | AltTabConstants.DUMMY_WIN_COUNT, 242 | Math.ceil(leftOver / 2) 243 | ); 244 | } else { 245 | dummyWinCount = AltTabConstants.DUMMY_WIN_COUNT; 246 | } 247 | 248 | if (nelement === 1) { 249 | this._switcher._select(0); 250 | this._progress = 0; 251 | } else { 252 | this._progress = getAvgProgressForIndex(1, nelement); 253 | this._switcher._select(1); 254 | } 255 | 256 | this._adjustment.value = 0; 257 | this._extState = AltTabExtState.ALTTABDELAY; 258 | 259 | if (this._altTabTimeoutId) 260 | GLib.source_remove(this._altTabTimeoutId); 261 | 262 | this._altTabTimeoutId = GLib.timeout_add( 263 | GLib.PRIORITY_DEFAULT, 264 | AltTabConstants.DELAY_DURATION, 265 | () => { 266 | Main.osdWindowManager.hideAll(); 267 | if (this._switcher) this._switcher.opacity = 255; 268 | this._adjustment.value = this._progress; 269 | this._extState = AltTabExtState.ALTTAB; 270 | this._altTabTimeoutId = 0; 271 | return GLib.SOURCE_REMOVE; 272 | } 273 | ); 274 | } else { 275 | this._switcher.destroy(); 276 | this._switcher = undefined; 277 | } 278 | } 279 | } 280 | 281 | _gestureUpdate( 282 | _gesture: never, 283 | _time: never, 284 | delta: number, 285 | distance: number 286 | ): void { 287 | if (this._extState > AltTabExtState.ALTTABDELAY) { 288 | this._progress = Math.clamp( 289 | this._progress + delta / distance, 290 | 0, 291 | 1 292 | ); 293 | this._adjustment.value = this._progress; 294 | } 295 | } 296 | 297 | _gestureEnd(): void { 298 | if (this._switcher) { 299 | const win = 300 | this._switcher._items[this._switcher._selectedIndex].window; 301 | Main.activateWindow(win); 302 | this._switcher.destroy(); 303 | this._switcher = undefined; 304 | } 305 | 306 | this._reset(); 307 | } 308 | 309 | private _reset() { 310 | if (this._extState > AltTabExtState.DEFAULT) { 311 | this._extState = AltTabExtState.DEFAULT; 312 | 313 | if (this._altTabTimeoutId) { 314 | GLib.source_remove(this._altTabTimeoutId); 315 | this._altTabTimeoutId = 0; 316 | } 317 | 318 | this._progress = 0; 319 | this._adjustment.value = 0; 320 | } 321 | 322 | this._extState = AltTabExtState.DEFAULT; 323 | } 324 | } 325 | -------------------------------------------------------------------------------- /extension/src/animations/arrow.ts: -------------------------------------------------------------------------------- 1 | import Gio from 'gi://Gio'; 2 | import Clutter from 'gi://Clutter'; 3 | import St from 'gi://St'; 4 | import GObject from 'gi://GObject'; 5 | import * as Util from 'resource:///org/gnome/shell/misc/util.js'; 6 | import {easeActor} from '../utils/environment.js'; 7 | import {WIGET_SHOWING_DURATION} from '../../constants.js'; 8 | 9 | declare type IconList = 10 | | 'arrow1-right-symbolic.svg' 11 | | 'arrow1-left-symbolic.svg'; 12 | 13 | const Circle = GObject.registerClass( 14 | class GIE_Circle extends St.Widget { 15 | constructor(style_class: string) { 16 | style_class = `gie-circle ${style_class}`; 17 | super({style_class}); 18 | this.set_pivot_point(0.5, 0.5); 19 | } 20 | } 21 | ); 22 | 23 | export const ArrowIconAnimation = GObject.registerClass( 24 | class GIE_ArrowIcon extends St.Widget { 25 | private _inner_circle: typeof Circle.prototype; 26 | private _outer_circle: typeof Circle.prototype; 27 | private _arrow_icon: St.Icon; 28 | private _transition?: { 29 | arrow: {from: number; end: number}; 30 | outer_circle: {from: number; end: number}; 31 | }; 32 | private _connectors: number[] = []; 33 | private _system_theme_setting: Gio.Settings; 34 | private _extension_path: string; 35 | 36 | constructor(extensionPath: string) { 37 | super(); 38 | 39 | this._extension_path = extensionPath; 40 | 41 | this._system_theme_setting = Gio.Settings.new( 42 | 'org.gnome.desktop.interface' 43 | ); 44 | this._connectors.push( 45 | this._system_theme_setting.connect( 46 | 'changed::color-scheme', 47 | () => this._updateStyle(this._system_theme_setting) 48 | ) 49 | ); 50 | 51 | const color_scheme = 52 | this._system_theme_setting.get_string('color-scheme'); 53 | this._inner_circle = 54 | color_scheme === 'prefer-light' 55 | ? new Circle('gie-inner-circle') 56 | : new Circle('gie-inner-circle-dark'); 57 | 58 | this._outer_circle = new Circle('gie-outer-circle'); 59 | this._arrow_icon = new St.Icon({style_class: 'gie-arrow-icon'}); 60 | 61 | this._inner_circle.set_clip_to_allocation(true); 62 | this._inner_circle.add_child(this._arrow_icon); 63 | 64 | this.add_child(this._outer_circle); 65 | this.add_child(this._inner_circle); 66 | } 67 | 68 | private _updateStyle(system_theme_setting: Gio.Settings): void { 69 | const color_scheme = 70 | system_theme_setting.get_string('color-scheme'); 71 | 72 | if (color_scheme === 'prefer-light') { 73 | this._inner_circle.set_style_class_name( 74 | 'gie-circle gie-inner-circle' 75 | ); 76 | } else { 77 | this._inner_circle.set_style_class_name( 78 | 'gie-circle gie-inner-circle-dark' 79 | ); 80 | } 81 | } 82 | 83 | gestureBegin(icon_name: IconList, from_left: boolean) { 84 | this._transition = { 85 | arrow: { 86 | from: this._inner_circle.width * (from_left ? -1 : 1), 87 | end: 0, 88 | }, 89 | outer_circle: { 90 | from: 1, 91 | end: 2, 92 | }, 93 | }; 94 | 95 | this._arrow_icon.translation_x = this._transition.arrow.from; 96 | this._outer_circle.scale_x = this._transition.outer_circle.from; 97 | this._outer_circle.scale_y = this._outer_circle.scale_x; 98 | this._arrow_icon.opacity = 255; 99 | 100 | // animating showing widget 101 | this.opacity = 0; 102 | this.show(); 103 | easeActor(this as St.Widget, { 104 | opacity: 255, 105 | mode: Clutter.AnimationMode.EASE_OUT_QUAD, 106 | duration: WIGET_SHOWING_DURATION, 107 | }); 108 | 109 | this._arrow_icon.set_gicon( 110 | Gio.Icon.new_for_string( 111 | `${this._extension_path}/assets/${icon_name}` 112 | ) 113 | ); 114 | } 115 | 116 | gestureUpdate(progress: number) { 117 | if (this._transition === undefined) return; 118 | 119 | this._arrow_icon.translation_x = Util.lerp( 120 | this._transition.arrow.from, 121 | this._transition.arrow.end, 122 | progress 123 | ); 124 | this._outer_circle.scale_x = Util.lerp( 125 | this._transition.outer_circle.from, 126 | this._transition.outer_circle.end, 127 | progress 128 | ); 129 | this._outer_circle.scale_y = this._outer_circle.scale_x; 130 | } 131 | 132 | gestureEnd(duration: number, progress: number, callback: () => void) { 133 | if (this._transition === undefined) return; 134 | 135 | easeActor(this as St.Widget, { 136 | opacity: 0, 137 | mode: Clutter.AnimationMode.EASE_OUT_QUAD, 138 | duration, 139 | }); 140 | 141 | const translation_x = Util.lerp( 142 | this._transition.arrow.from, 143 | this._transition.arrow.end, 144 | progress 145 | ); 146 | easeActor(this._arrow_icon, { 147 | translation_x, 148 | duration, 149 | mode: Clutter.AnimationMode.EASE_OUT_EXPO, 150 | onStopped: () => { 151 | callback(); 152 | this.hide(); 153 | this._arrow_icon.opacity = 0; 154 | this._arrow_icon.translation_x = 0; 155 | this._outer_circle.scale_x = 1; 156 | this._outer_circle.scale_y = 1; 157 | }, 158 | }); 159 | 160 | const scale = Util.lerp( 161 | this._transition.outer_circle.from, 162 | this._transition.outer_circle.end, 163 | progress 164 | ); 165 | easeActor(this._outer_circle, { 166 | scale_x: scale, 167 | scale_y: scale, 168 | duration, 169 | mode: Clutter.AnimationMode.EASE_OUT_EXPO, 170 | }); 171 | } 172 | 173 | destroy() { 174 | this._connectors.forEach(connector => 175 | this._system_theme_setting.disconnect(connector) 176 | ); 177 | this._connectors = []; 178 | this._extension_path = ''; 179 | super.destroy(); 180 | } 181 | } 182 | ); 183 | -------------------------------------------------------------------------------- /extension/src/forwardBack.ts: -------------------------------------------------------------------------------- 1 | import Clutter from 'gi://Clutter'; 2 | import Shell from 'gi://Shell'; 3 | import Meta from 'gi://Meta'; 4 | import * as Main from 'resource:///org/gnome/shell/ui/main.js'; 5 | import {SwipeTracker} from 'resource:///org/gnome/shell/ui/swipeTracker.js'; 6 | import {ExtSettings} from '../constants.js'; 7 | import {ArrowIconAnimation} from './animations/arrow.js'; 8 | import {createSwipeTracker} from './swipeTracker.js'; 9 | import {getVirtualKeyboard, IVirtualKeyboard} from './utils/keyboard.js'; 10 | import {ForwardBackKeyBinds} from '../common/settings.js'; 11 | 12 | enum AnimationState { 13 | WAITING = 0, // waiting to cross threshold 14 | DEFAULT = WAITING, 15 | LEFT = -1, 16 | RIGHT = 1, 17 | } 18 | 19 | enum SwipeGestureDirection { 20 | LeftToRight = 1, 21 | RightToLeft = 2, 22 | } 23 | 24 | const SnapPointThreshold = 0.1; 25 | 26 | export type AppForwardBackKeyBinds = Record< 27 | string, 28 | [ForwardBackKeyBinds, boolean] 29 | >; 30 | 31 | export class ForwardBackGestureExtension implements ISubExtension { 32 | private _connectHandlers: number[]; 33 | private _swipeTracker: SwipeTracker; 34 | private _keyboard: IVirtualKeyboard; 35 | private _arrowIconAnimation: typeof ArrowIconAnimation.prototype; 36 | private _animationState = AnimationState.WAITING; 37 | private _appForwardBackKeyBinds: AppForwardBackKeyBinds; 38 | private _windowTracker: Shell.WindowTracker; 39 | private _focusWindow?: Meta.Window | null; 40 | 41 | constructor( 42 | appForwardBackKeyBinds: AppForwardBackKeyBinds, 43 | extensionPath: string, 44 | enableVerticalSwipe: boolean 45 | ) { 46 | this._appForwardBackKeyBinds = appForwardBackKeyBinds; 47 | this._windowTracker = Shell.WindowTracker.get_default(); 48 | this._keyboard = getVirtualKeyboard(); 49 | 50 | this._swipeTracker = createSwipeTracker( 51 | global.stage, 52 | [4, 3], //TODO: add support for vertical hold and swipe without disabling 3/4-fingers vertical swipe 53 | Shell.ActionMode.NORMAL, 54 | enableVerticalSwipe 55 | ? Clutter.Orientation.VERTICAL 56 | : Clutter.Orientation.HORIZONTAL, 57 | false, 58 | 1, 59 | {allowTouch: false} 60 | ); 61 | 62 | this._connectHandlers = [ 63 | this._swipeTracker.connect('begin', this._gestureBegin.bind(this)), 64 | this._swipeTracker.connect( 65 | 'update', 66 | this._gestureUpdate.bind(this) 67 | ), 68 | this._swipeTracker.connect('end', this._gestureEnd.bind(this)), 69 | ]; 70 | 71 | this._arrowIconAnimation = new ArrowIconAnimation(extensionPath); 72 | this._arrowIconAnimation.hide(); 73 | Main.layoutManager.uiGroup.add_child(this._arrowIconAnimation); 74 | } 75 | 76 | destroy(): void { 77 | this._connectHandlers.forEach(handle => 78 | this._swipeTracker.disconnect(handle) 79 | ); 80 | this._connectHandlers = []; 81 | this._swipeTracker.destroy(); 82 | this._arrowIconAnimation.destroy(); 83 | } 84 | 85 | _gestureBegin(_tracker: SwipeTracker): void { 86 | this._focusWindow = 87 | global.display.get_focus_window() as Meta.Window | null; 88 | if (!this._focusWindow) return; 89 | this._animationState = AnimationState.WAITING; 90 | _tracker.confirmSwipe( 91 | global.screen_width, 92 | [AnimationState.LEFT, AnimationState.DEFAULT, AnimationState.RIGHT], 93 | AnimationState.DEFAULT, 94 | AnimationState.DEFAULT 95 | ); 96 | } 97 | 98 | _gestureUpdate(_tracker: SwipeTracker, progress: number): void { 99 | switch (this._animationState) { 100 | case AnimationState.WAITING: 101 | if ( 102 | Math.abs(progress - AnimationState.DEFAULT) < 103 | SnapPointThreshold 104 | ) 105 | return; 106 | this._showArrow(progress); 107 | break; 108 | case AnimationState.RIGHT: 109 | progress = 110 | (progress - SnapPointThreshold) / 111 | (AnimationState.RIGHT - SnapPointThreshold); 112 | this._arrowIconAnimation.gestureUpdate( 113 | Math.clamp(progress, 0, 1) 114 | ); 115 | break; 116 | case AnimationState.LEFT: 117 | progress = 118 | (progress + SnapPointThreshold) / 119 | (AnimationState.LEFT + SnapPointThreshold); 120 | this._arrowIconAnimation.gestureUpdate( 121 | Math.clamp(progress, 0, 1) 122 | ); 123 | } 124 | } 125 | 126 | _gestureEnd( 127 | _tracker: SwipeTracker, 128 | duration: number, 129 | progress: AnimationState 130 | ): void { 131 | if (this._animationState === AnimationState.WAITING) { 132 | if (progress === AnimationState.DEFAULT) return; 133 | this._showArrow(progress); 134 | } 135 | 136 | switch (this._animationState) { 137 | case AnimationState.RIGHT: 138 | progress = 139 | (progress - SnapPointThreshold) / 140 | (AnimationState.RIGHT - SnapPointThreshold); 141 | progress = Math.clamp(progress, 0, 1); 142 | this._arrowIconAnimation.gestureEnd(duration, progress, () => { 143 | if (progress !== 0) { 144 | // bring left page to right 145 | const keys = this._getClutterKeyForFocusedApp( 146 | SwipeGestureDirection.LeftToRight 147 | ); 148 | this._keyboard.sendKeys(keys); 149 | } 150 | }); 151 | 152 | break; 153 | case AnimationState.LEFT: 154 | progress = 155 | (progress + SnapPointThreshold) / 156 | (AnimationState.LEFT + SnapPointThreshold); 157 | progress = Math.clamp(progress, 0, 1); 158 | this._arrowIconAnimation.gestureEnd(duration, progress, () => { 159 | if (progress !== 0) { 160 | // bring right page to left 161 | const keys = this._getClutterKeyForFocusedApp( 162 | SwipeGestureDirection.RightToLeft 163 | ); 164 | this._keyboard.sendKeys(keys); 165 | } 166 | }); 167 | } 168 | } 169 | 170 | _showArrow(progress: number) { 171 | const [height, width] = [ 172 | this._arrowIconAnimation.height, 173 | this._arrowIconAnimation.width, 174 | ]; 175 | const workArea = this._getWorkArea(); 176 | 177 | if (progress > AnimationState.DEFAULT) { 178 | this._animationState = AnimationState.RIGHT; 179 | this._arrowIconAnimation.gestureBegin( 180 | 'arrow1-left-symbolic.svg', 181 | true 182 | ); 183 | this._arrowIconAnimation.set_position( 184 | workArea.x + width, 185 | workArea.y + Math.round((workArea.height - height) / 2) 186 | ); 187 | } else { 188 | this._animationState = AnimationState.LEFT; 189 | this._arrowIconAnimation.gestureBegin( 190 | 'arrow1-right-symbolic.svg', 191 | false 192 | ); 193 | this._arrowIconAnimation.set_position( 194 | workArea.x + workArea.width - 2 * width, 195 | workArea.y + Math.round((workArea.height - height) / 2) 196 | ); 197 | } 198 | } 199 | 200 | _getWorkArea() { 201 | const window = this._focusWindow; 202 | if (window) return window.get_frame_rect(); 203 | return Main.layoutManager.getWorkAreaForMonitor( 204 | Main.layoutManager.currentMonitor?.index ?? 0 205 | ); 206 | } 207 | 208 | /** 209 | * @param gestureDirection direction of swipe gesture left to right or right to left 210 | */ 211 | _getClutterKeyForFocusedApp(gestureDirection: SwipeGestureDirection) { 212 | const focusApp = this._windowTracker.focus_app as Shell.App | null; 213 | const keyBind = focusApp 214 | ? this._appForwardBackKeyBinds[focusApp.get_id()] 215 | : null; 216 | 217 | if (keyBind) { 218 | // if keyBind[1] is true => reverse order or keys 219 | const returnBackKey = 220 | (gestureDirection === SwipeGestureDirection.LeftToRight) !== 221 | keyBind[1]; 222 | 223 | switch (keyBind[0]) { 224 | case ForwardBackKeyBinds['Forward/Backward']: 225 | return [ 226 | returnBackKey ? Clutter.KEY_Back : Clutter.KEY_Forward, 227 | ]; 228 | case ForwardBackKeyBinds['Page Up/Down']: 229 | return [ 230 | returnBackKey 231 | ? Clutter.KEY_Page_Up 232 | : Clutter.KEY_Page_Down, 233 | ]; 234 | case ForwardBackKeyBinds['Right/Left']: 235 | return [ 236 | returnBackKey ? Clutter.KEY_Left : Clutter.KEY_Right, 237 | ]; 238 | case ForwardBackKeyBinds['Audio Next/Prev']: 239 | return [ 240 | returnBackKey 241 | ? Clutter.KEY_AudioPrev 242 | : Clutter.KEY_AudioNext, 243 | ]; 244 | case ForwardBackKeyBinds['Tab Next/Prev']: 245 | return [ 246 | Clutter.KEY_Control_L, 247 | returnBackKey 248 | ? Clutter.KEY_Page_Up 249 | : Clutter.KEY_Page_Down, 250 | ]; 251 | } 252 | } 253 | 254 | // default key bind 255 | return [ 256 | gestureDirection === SwipeGestureDirection.LeftToRight 257 | ? Clutter.KEY_Back 258 | : Clutter.KEY_Forward, 259 | ]; 260 | } 261 | } 262 | -------------------------------------------------------------------------------- /extension/src/gestures.ts: -------------------------------------------------------------------------------- 1 | import Clutter from 'gi://Clutter'; 2 | import GObject from 'gi://GObject'; 3 | import Shell from 'gi://Shell'; 4 | import {WorkspaceAnimationController} from 'resource:///org/gnome/shell/ui/workspaceAnimation.js'; 5 | import { 6 | SwipeTracker, 7 | CustomEventType, 8 | TouchpadGesture, 9 | } from 'resource:///org/gnome/shell/ui/swipeTracker.js'; 10 | import {OverviewAdjustment} from 'resource:///org/gnome/shell/ui/overviewControls.js'; 11 | import * as Main from 'resource:///org/gnome/shell/ui/main.js'; 12 | import {ExtSettings, OverviewControlsState} from '../constants.js'; 13 | import {createSwipeTracker, TouchpadSwipeGesture} from './swipeTracker.js'; 14 | 15 | interface ShallowSwipeTracker { 16 | orientation: Clutter.Orientation; 17 | confirmSwipe( 18 | distance: number, 19 | snapPoints: number[], 20 | currentProgress: number, 21 | cancelProgress: number 22 | ): void; 23 | } 24 | 25 | declare type TouchPadSwipeTracker = Required['_touchpadGesture']; 26 | declare interface ShellSwipeTracker { 27 | swipeTracker: SwipeTracker; 28 | nfingers: number[]; 29 | disableOldGesture: boolean; 30 | modes: Shell.ActionMode; 31 | followNaturalScroll: boolean; 32 | gestureSpeed?: number; 33 | checkAllowedGesture?: (event: CustomEventType) => boolean; 34 | } 35 | 36 | /** 37 | * 38 | * @param tracker 39 | */ 40 | function connectTouchpadEventToTracker(tracker: TouchPadSwipeTracker) { 41 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 42 | (global.stage as any).connectObject( 43 | 'captured-event::touchpad', 44 | tracker._handleEvent.bind(tracker), 45 | tracker 46 | ); 47 | } 48 | 49 | /** 50 | * 51 | * @param tracker 52 | */ 53 | function disconnectTouchpadEventFromTracker(tracker: TouchPadSwipeTracker) { 54 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 55 | (global.stage as any).disconnectObject(tracker); 56 | } 57 | 58 | abstract class SwipeTrackerEndPointsModifer { 59 | protected _firstVal = 0; 60 | protected _lastVal = 0; 61 | 62 | protected abstract _swipeTracker: SwipeTracker; 63 | 64 | public apply(): void { 65 | this._swipeTracker.connect('begin', this._gestureBegin.bind(this)); 66 | this._swipeTracker.connect('update', this._gestureUpdate.bind(this)); 67 | this._swipeTracker.connect('end', this._gestureEnd.bind(this)); 68 | } 69 | 70 | protected abstract _gestureBegin( 71 | tracker: SwipeTracker, 72 | monitor: never 73 | ): void; 74 | 75 | protected abstract _gestureUpdate( 76 | tracker: SwipeTracker, 77 | progress: number 78 | ): void; 79 | 80 | protected abstract _gestureEnd( 81 | tracker: SwipeTracker, 82 | duration: number, 83 | progress: number 84 | ): void; 85 | 86 | protected _modifySnapPoints( 87 | tracker: SwipeTracker, 88 | callback: (tracker: ShallowSwipeTracker) => void 89 | ) { 90 | const _tracker: ShallowSwipeTracker = { 91 | orientation: tracker.orientation, 92 | confirmSwipe: ( 93 | distance, 94 | snapPoints, 95 | currentProgress, 96 | cancelProgress 97 | ) => { 98 | this._firstVal = snapPoints[0]; 99 | this._lastVal = snapPoints[snapPoints.length - 1]; 100 | 101 | snapPoints.unshift(this._firstVal - 1); 102 | snapPoints.push(this._lastVal + 1); 103 | 104 | tracker.confirmSwipe( 105 | distance, 106 | snapPoints, 107 | currentProgress, 108 | cancelProgress 109 | ); 110 | }, 111 | }; 112 | 113 | callback(_tracker); 114 | } 115 | 116 | public destroy(): void { 117 | if (this._swipeTracker) { 118 | this._swipeTracker.enabled = false; 119 | } 120 | } 121 | } 122 | 123 | class WorkspaceAnimationModifier extends SwipeTrackerEndPointsModifer { 124 | private _workspaceAnimation: WorkspaceAnimationController; 125 | protected _swipeTracker: SwipeTracker; 126 | 127 | constructor( 128 | nfingers: number[], 129 | wm: typeof Main.wm, 130 | orientation?: Clutter.Orientation 131 | ) { 132 | super(); 133 | this._workspaceAnimation = wm._workspaceAnimation; 134 | this._swipeTracker = createSwipeTracker( 135 | global.stage, 136 | nfingers, 137 | Shell.ActionMode.NORMAL, 138 | orientation ?? Clutter.Orientation.HORIZONTAL, 139 | ExtSettings.FOLLOW_NATURAL_SCROLL, 140 | 1, 141 | {allowTouch: false} 142 | ); 143 | } 144 | 145 | apply(): void { 146 | if (this._workspaceAnimation._swipeTracker._touchpadGesture) 147 | disconnectTouchpadEventFromTracker( 148 | this._workspaceAnimation._swipeTracker._touchpadGesture 149 | ); 150 | 151 | super.apply(); 152 | } 153 | 154 | protected _gestureBegin(tracker: SwipeTracker, monitor: number): void { 155 | super._modifySnapPoints(tracker, shallowTracker => { 156 | this._workspaceAnimation._switchWorkspaceBegin( 157 | shallowTracker, 158 | monitor 159 | ); 160 | }); 161 | } 162 | 163 | protected _gestureUpdate(tracker: SwipeTracker, progress: number): void { 164 | if (progress < this._firstVal) 165 | progress = this._firstVal - (this._firstVal - progress) * 0.05; 166 | else if (progress > this._lastVal) 167 | progress = this._lastVal + (progress - this._lastVal) * 0.05; 168 | 169 | this._workspaceAnimation._switchWorkspaceUpdate(tracker, progress); 170 | } 171 | 172 | protected _gestureEnd( 173 | tracker: SwipeTracker, 174 | duration: number, 175 | progress: number 176 | ): void { 177 | progress = Math.clamp(progress, this._firstVal, this._lastVal); 178 | this._workspaceAnimation._switchWorkspaceEnd( 179 | tracker, 180 | duration, 181 | progress 182 | ); 183 | } 184 | 185 | destroy(): void { 186 | this._swipeTracker.destroy(); 187 | const swipeTracker = this._workspaceAnimation._swipeTracker; 188 | if (swipeTracker._touchpadGesture) 189 | connectTouchpadEventToTracker(swipeTracker._touchpadGesture); 190 | 191 | super.destroy(); 192 | } 193 | } 194 | 195 | export class GestureExtension implements ISubExtension { 196 | private _stateAdjustment: OverviewAdjustment; 197 | private _swipeTrackers: ShellSwipeTracker[]; 198 | private _verticalWorkspaceAnimationModifier?: WorkspaceAnimationModifier; 199 | private _horizontalWorkspaceAnimationModifier?: WorkspaceAnimationModifier; 200 | 201 | constructor() { 202 | this._stateAdjustment = 203 | Main.overview._overview._controls._stateAdjustment; 204 | 205 | this._swipeTrackers = [ 206 | { 207 | swipeTracker: 208 | Main.overview._overview._controls._workspacesDisplay 209 | ._swipeTracker, 210 | nfingers: [3, 4], 211 | disableOldGesture: true, 212 | followNaturalScroll: ExtSettings.FOLLOW_NATURAL_SCROLL, 213 | modes: Shell.ActionMode.OVERVIEW, 214 | gestureSpeed: 1, 215 | checkAllowedGesture: (event: CustomEventType) => { 216 | if ( 217 | Main.overview._overview._controls._searchController 218 | .searchActive 219 | ) 220 | return false; 221 | 222 | if (event.get_touchpad_gesture_finger_count() === 4) 223 | return true; 224 | else 225 | return ( 226 | this._stateAdjustment.value === 227 | OverviewControlsState.WINDOW_PICKER 228 | ); 229 | }, 230 | }, 231 | { 232 | swipeTracker: 233 | Main.overview._overview._controls._appDisplay._swipeTracker, 234 | nfingers: [3], 235 | disableOldGesture: true, 236 | followNaturalScroll: ExtSettings.FOLLOW_NATURAL_SCROLL, 237 | modes: Shell.ActionMode.OVERVIEW, 238 | checkAllowedGesture: () => { 239 | if ( 240 | Main.overview._overview._controls._searchController 241 | .searchActive 242 | ) 243 | return false; 244 | 245 | return ( 246 | this._stateAdjustment.value === 247 | OverviewControlsState.APP_GRID 248 | ); 249 | }, 250 | }, 251 | ]; 252 | } 253 | 254 | setVerticalWorkspceAnimationModifier(nfingers: number[]) { 255 | this._verticalWorkspaceAnimationModifier = 256 | new WorkspaceAnimationModifier( 257 | nfingers, 258 | Main.wm, 259 | Clutter.Orientation.VERTICAL 260 | ); 261 | } 262 | 263 | setHorizontalWorkspaceAnimationModifier(nfingers: number[]) { 264 | this._horizontalWorkspaceAnimationModifier = 265 | new WorkspaceAnimationModifier( 266 | nfingers, 267 | Main.wm, 268 | Clutter.Orientation.HORIZONTAL 269 | ); 270 | } 271 | 272 | apply(): void { 273 | this._verticalWorkspaceAnimationModifier?.apply(); 274 | this._horizontalWorkspaceAnimationModifier?.apply(); 275 | 276 | this._swipeTrackers.forEach(entry => { 277 | const { 278 | swipeTracker, 279 | nfingers, 280 | disableOldGesture, 281 | followNaturalScroll, 282 | modes, 283 | checkAllowedGesture, 284 | } = entry; 285 | 286 | const gestureSpeed = entry.gestureSpeed ?? 1; 287 | const touchpadGesture = new TouchpadSwipeGesture( 288 | nfingers, 289 | modes, 290 | swipeTracker.orientation, 291 | followNaturalScroll, 292 | checkAllowedGesture, 293 | gestureSpeed 294 | ); 295 | 296 | this._attachGestureToTracker( 297 | swipeTracker, 298 | touchpadGesture, 299 | disableOldGesture 300 | ); 301 | }); 302 | } 303 | 304 | destroy(): void { 305 | this._swipeTrackers.reverse().forEach(entry => { 306 | const {swipeTracker, disableOldGesture} = entry; 307 | swipeTracker._touchpadGesture?.destroy(); 308 | swipeTracker._touchpadGesture = swipeTracker._oldTouchpadGesture; 309 | swipeTracker._oldTouchpadGesture = undefined; 310 | if (swipeTracker._touchpadGesture && disableOldGesture) 311 | connectTouchpadEventToTracker(swipeTracker._touchpadGesture); 312 | }); 313 | 314 | this._verticalWorkspaceAnimationModifier?.destroy(); 315 | this._horizontalWorkspaceAnimationModifier?.destroy(); 316 | } 317 | 318 | _attachGestureToTracker( 319 | swipeTracker: SwipeTracker, 320 | touchpadSwipeGesture: 321 | | typeof TouchpadSwipeGesture.prototype 322 | | TouchpadGesture, 323 | disablePrevious: boolean 324 | ): void { 325 | if (swipeTracker._touchpadGesture && disablePrevious) { 326 | disconnectTouchpadEventFromTracker(swipeTracker._touchpadGesture); 327 | swipeTracker._oldTouchpadGesture = swipeTracker._touchpadGesture; 328 | } 329 | 330 | swipeTracker._touchpadGesture = touchpadSwipeGesture as TouchpadGesture; 331 | swipeTracker._touchpadGesture.connect( 332 | 'begin', 333 | swipeTracker._beginGesture.bind(swipeTracker) 334 | ); 335 | swipeTracker._touchpadGesture.connect( 336 | 'update', 337 | swipeTracker._updateGesture.bind(swipeTracker) 338 | ); 339 | swipeTracker._touchpadGesture.connect( 340 | 'end', 341 | swipeTracker._endTouchpadGesture.bind(swipeTracker) 342 | ); 343 | swipeTracker.bind_property( 344 | 'enabled', 345 | swipeTracker._touchpadGesture, 346 | 'enabled', 347 | 0 348 | ); 349 | swipeTracker.bind_property( 350 | 'orientation', 351 | swipeTracker._touchpadGesture, 352 | 'orientation', 353 | GObject.BindingFlags.SYNC_CREATE 354 | ); 355 | } 356 | } 357 | -------------------------------------------------------------------------------- /extension/src/overviewRoundTrip.ts: -------------------------------------------------------------------------------- 1 | import Clutter from 'gi://Clutter'; 2 | import Shell from 'gi://Shell'; 3 | import * as Main from 'resource:///org/gnome/shell/ui/main.js'; 4 | import { 5 | ControlsManager, 6 | OverviewAdjustment, 7 | } from 'resource:///org/gnome/shell/ui/overviewControls.js'; 8 | import {SwipeTracker} from 'resource:///org/gnome/shell/ui/swipeTracker.js'; 9 | import {createSwipeTracker} from './swipeTracker.js'; 10 | import {OverviewNavigationState} from '../common/settings.js'; 11 | import {ExtSettings, OverviewControlsState} from '../constants.js'; 12 | 13 | enum ExtensionState { 14 | // DISABLED = 0, 15 | DEFAULT = 1, 16 | CUSTOM = 2, 17 | } 18 | 19 | export class OverviewRoundTripGestureExtension implements ISubExtension { 20 | private _overviewControls: ControlsManager; 21 | private _stateAdjustment: OverviewAdjustment; 22 | private _oldGetStateTransitionParams: typeof OverviewAdjustment.prototype.getStateTransitionParams; 23 | private _progress = 0; 24 | private _extensionState = ExtensionState.DEFAULT; 25 | private _shownEventId = 0; 26 | private _hiddenEventId = 0; 27 | private _navigationStates: OverviewNavigationState; 28 | private _verticalSwipeTracker?: typeof SwipeTracker.prototype; 29 | private _horizontalSwipeTracker?: typeof SwipeTracker.prototype; 30 | private _verticalConnectors?: number[]; 31 | private _horizontalConnectors?: number[]; 32 | 33 | constructor(navigationStates: OverviewNavigationState) { 34 | this._navigationStates = navigationStates; 35 | this._overviewControls = Main.overview._overview._controls; 36 | this._stateAdjustment = this._overviewControls._stateAdjustment; 37 | this._oldGetStateTransitionParams = 38 | this._stateAdjustment.getStateTransitionParams; 39 | this._progress = 0; 40 | } 41 | 42 | _getStateTransitionParams(): typeof OverviewAdjustment.prototype.getStateTransitionParams.prototype { 43 | if (this._extensionState <= ExtensionState.DEFAULT) { 44 | return this._oldGetStateTransitionParams.call( 45 | this._stateAdjustment 46 | ); 47 | } else if (this._extensionState === ExtensionState.CUSTOM) { 48 | const currentState = this._stateAdjustment.value; 49 | const initialState = OverviewControlsState.HIDDEN; 50 | const finalState = OverviewControlsState.APP_GRID; 51 | 52 | const length = Math.abs(finalState - initialState); 53 | const progress = Math.abs((currentState - initialState) / length); 54 | 55 | return { 56 | transitioning: true, 57 | currentState, 58 | initialState, 59 | finalState, 60 | progress, 61 | }; 62 | } 63 | } 64 | 65 | setVerticalSwipeTracker(nfingers: number[]) { 66 | this._verticalConnectors?.forEach(connector => 67 | this._verticalSwipeTracker?.disconnect(connector) 68 | ); 69 | this._verticalSwipeTracker?.destroy(); 70 | 71 | this._verticalSwipeTracker = createSwipeTracker( 72 | global.stage, 73 | nfingers, 74 | Shell.ActionMode.NORMAL | Shell.ActionMode.OVERVIEW, 75 | Clutter.Orientation.VERTICAL, 76 | ExtSettings.DEFAULT_OVERVIEW_GESTURE_DIRECTION 77 | ); 78 | 79 | this._verticalConnectors = [ 80 | this._verticalSwipeTracker.connect( 81 | 'begin', 82 | this._gestureBegin.bind(this) 83 | ), 84 | this._verticalSwipeTracker.connect( 85 | 'update', 86 | this._gestureUpdate.bind(this) 87 | ), 88 | this._verticalSwipeTracker.connect( 89 | 'end', 90 | this._gestureEnd.bind(this) 91 | ), 92 | ]; 93 | } 94 | 95 | setHorizontalSwipeTracker(nfingers: number[]) { 96 | this._horizontalConnectors?.forEach(connector => 97 | this._horizontalSwipeTracker?.disconnect(connector) 98 | ); 99 | this._horizontalSwipeTracker?.destroy(); 100 | 101 | this._horizontalSwipeTracker = createSwipeTracker( 102 | global.stage, 103 | nfingers, 104 | Shell.ActionMode.NORMAL | Shell.ActionMode.OVERVIEW, 105 | Clutter.Orientation.HORIZONTAL, 106 | ExtSettings.DEFAULT_OVERVIEW_GESTURE_DIRECTION 107 | ); 108 | 109 | this._horizontalConnectors = [ 110 | this._horizontalSwipeTracker.connect( 111 | 'begin', 112 | this._gestureBegin.bind(this) 113 | ), 114 | this._horizontalSwipeTracker.connect( 115 | 'update', 116 | this._gestureUpdate.bind(this) 117 | ), 118 | this._horizontalSwipeTracker.connect( 119 | 'end', 120 | this._gestureEnd.bind(this) 121 | ), 122 | ]; 123 | } 124 | 125 | apply(): void { 126 | Main.overview._swipeTracker.enabled = false; 127 | 128 | // override 'getStateTransitionParams' function 129 | this._stateAdjustment.getStateTransitionParams = 130 | this._getStateTransitionParams.bind(this); 131 | 132 | this._extensionState = ExtensionState.DEFAULT; 133 | this._progress = 0; 134 | 135 | // reset extension state to default, when overview is shown and hidden (not showing/hidding event) 136 | this._shownEventId = Main.overview.connect( 137 | 'shown', 138 | () => (this._extensionState = ExtensionState.DEFAULT) 139 | ); 140 | this._hiddenEventId = Main.overview.connect( 141 | 'hidden', 142 | () => (this._extensionState = ExtensionState.DEFAULT) 143 | ); 144 | } 145 | 146 | destroy(): void { 147 | this._verticalConnectors?.forEach(connector => 148 | this._verticalSwipeTracker?.disconnect(connector) 149 | ); 150 | this._verticalSwipeTracker?.destroy(); 151 | this._verticalConnectors = undefined; 152 | this._verticalSwipeTracker = undefined; 153 | 154 | this._horizontalConnectors?.forEach(connector => 155 | this._horizontalSwipeTracker?.disconnect(connector) 156 | ); 157 | this._horizontalSwipeTracker?.destroy(); 158 | this._horizontalConnectors = undefined; 159 | this._horizontalSwipeTracker = undefined; 160 | 161 | Main.overview._swipeTracker.enabled = true; 162 | this._stateAdjustment.getStateTransitionParams = 163 | this._oldGetStateTransitionParams.bind(this._stateAdjustment); 164 | Main.overview.disconnect(this._shownEventId); 165 | Main.overview.disconnect(this._hiddenEventId); 166 | } 167 | 168 | _gestureBegin(tracker: typeof SwipeTracker.prototype): void { 169 | const _tracker = { 170 | confirmSwipe: ( 171 | distance: number, 172 | _snapPoints: number[], 173 | currentProgress: number, 174 | cancelProgress: number 175 | ) => { 176 | tracker.confirmSwipe( 177 | distance, 178 | this._getGestureSnapPoints(), 179 | currentProgress, 180 | cancelProgress 181 | ); 182 | }, 183 | }; 184 | 185 | Main.overview._gestureBegin(_tracker); 186 | this._progress = this._stateAdjustment.value; 187 | this._extensionState = ExtensionState.DEFAULT; 188 | } 189 | 190 | _gestureUpdate( 191 | tracker: typeof SwipeTracker.prototype, 192 | progress: number 193 | ): void { 194 | if ( 195 | progress < OverviewControlsState.HIDDEN || 196 | progress > OverviewControlsState.APP_GRID 197 | ) 198 | this._extensionState = ExtensionState.CUSTOM; 199 | else this._extensionState = ExtensionState.DEFAULT; 200 | 201 | this._progress = progress; 202 | 203 | // log(`update: progress=${progress}, overview progress=${this._getOverviewProgressValue(progress)}`); 204 | Main.overview._gestureUpdate( 205 | tracker, 206 | this._getOverviewProgressValue(progress) 207 | ); 208 | } 209 | 210 | _gestureEnd( 211 | tracker: typeof SwipeTracker.prototype, 212 | duration: number, 213 | endProgress: number 214 | ): void { 215 | if (this._progress < OverviewControlsState.HIDDEN) { 216 | this._extensionState = ExtensionState.CUSTOM; 217 | endProgress = 218 | endProgress >= OverviewControlsState.HIDDEN 219 | ? OverviewControlsState.HIDDEN 220 | : OverviewControlsState.APP_GRID; 221 | } else if (this._progress > OverviewControlsState.APP_GRID) { 222 | this._extensionState = ExtensionState.CUSTOM; 223 | endProgress = 224 | endProgress <= OverviewControlsState.APP_GRID 225 | ? OverviewControlsState.APP_GRID 226 | : OverviewControlsState.HIDDEN; 227 | } else { 228 | this._extensionState = ExtensionState.DEFAULT; 229 | endProgress = Math.clamp( 230 | endProgress, 231 | OverviewControlsState.HIDDEN, 232 | OverviewControlsState.APP_GRID 233 | ); 234 | } 235 | 236 | // log(`end: progress=${this._progress}, endProgress=${endProgress}, \ 237 | // overview progress=${this._getOverviewProgressValue(endProgress)}`) 238 | Main.overview._gestureEnd(tracker, duration, endProgress); 239 | } 240 | 241 | _getOverviewProgressValue(progress: number): number { 242 | if (progress < OverviewControlsState.HIDDEN) { 243 | return Math.min( 244 | OverviewControlsState.APP_GRID, 245 | 2 * Math.abs(OverviewControlsState.HIDDEN - progress) 246 | ); 247 | } else if (progress > OverviewControlsState.APP_GRID) { 248 | return Math.min( 249 | OverviewControlsState.APP_GRID, 250 | 2 * Math.abs(OverviewControlsState.HIDDEN_N - progress) 251 | ); 252 | } 253 | 254 | return progress; 255 | } 256 | 257 | private _getGestureSnapPoints(): number[] { 258 | switch (this._navigationStates) { 259 | case OverviewNavigationState.CYCLIC: 260 | return [ 261 | OverviewControlsState.APP_GRID_P, 262 | OverviewControlsState.HIDDEN, 263 | OverviewControlsState.WINDOW_PICKER, 264 | OverviewControlsState.APP_GRID, 265 | OverviewControlsState.HIDDEN_N, 266 | ]; 267 | case OverviewNavigationState.GNOME: 268 | return [ 269 | OverviewControlsState.HIDDEN, 270 | OverviewControlsState.WINDOW_PICKER, 271 | OverviewControlsState.APP_GRID, 272 | ]; 273 | case OverviewNavigationState.WINDOW_PICKER_ONLY: 274 | return [ 275 | OverviewControlsState.HIDDEN, 276 | OverviewControlsState.WINDOW_PICKER, 277 | ]; 278 | } 279 | } 280 | } 281 | -------------------------------------------------------------------------------- /extension/src/pinchGestures/closeWindow.ts: -------------------------------------------------------------------------------- 1 | import Clutter from 'gi://Clutter'; 2 | import Meta from 'gi://Meta'; 3 | import Shell from 'gi://Shell'; 4 | import St from 'gi://St'; 5 | import * as Main from 'resource:///org/gnome/shell/ui/main.js'; 6 | import * as Util from 'resource:///org/gnome/shell/misc/util.js'; 7 | import {PinchGestureType} from '../../common/settings.js'; 8 | import {WIGET_SHOWING_DURATION} from '../../constants.js'; 9 | import {TouchpadPinchGesture} from './pinchTracker.js'; 10 | import {easeActor} from '../utils/environment.js'; 11 | import {getVirtualKeyboard, IVirtualKeyboard} from '../utils/keyboard.js'; 12 | 13 | const END_OPACITY = 0; 14 | const END_SCALE = 0.5; 15 | 16 | enum CloseWindowGestureState { 17 | PINCH_IN = -1, 18 | DEFAULT = 0, 19 | } 20 | 21 | declare type Type_TouchpadPinchGesture = typeof TouchpadPinchGesture.prototype; 22 | 23 | export class CloseWindowExtension implements ISubExtension { 24 | private _closeType: 25 | | PinchGestureType.CLOSE_DOCUMENT 26 | | PinchGestureType.CLOSE_WINDOW; 27 | private _keyboard: IVirtualKeyboard; 28 | private _pinchTracker: Type_TouchpadPinchGesture; 29 | private _preview: St.Widget; 30 | private _focusWindow?: Meta.Window | null; 31 | 32 | constructor( 33 | nfingers: number[], 34 | closeType: 35 | | PinchGestureType.CLOSE_DOCUMENT 36 | | PinchGestureType.CLOSE_WINDOW 37 | ) { 38 | this._closeType = closeType; 39 | this._keyboard = getVirtualKeyboard(); 40 | 41 | this._preview = new St.Widget({ 42 | reactive: false, 43 | style_class: 'gie-close-window-preview', 44 | visible: false, 45 | }); 46 | 47 | this._preview.set_pivot_point(0.5, 0.5); 48 | Main.layoutManager.uiGroup.add_child(this._preview); 49 | 50 | this._pinchTracker = new TouchpadPinchGesture({ 51 | nfingers: nfingers, 52 | allowedModes: Shell.ActionMode.NORMAL, 53 | pinchSpeed: 0.25, 54 | }); 55 | 56 | this._pinchTracker.connect('begin', this.gestureBegin.bind(this)); 57 | this._pinchTracker.connect('update', this.gestureUpdate.bind(this)); 58 | this._pinchTracker.connect('end', this.gestureEnd.bind(this)); 59 | } 60 | 61 | destroy(): void { 62 | this._pinchTracker.destroy(); 63 | this._preview.destroy(); 64 | } 65 | 66 | gestureBegin(tracker: Type_TouchpadPinchGesture) { 67 | // if we are currently in middle of animations, ignore this event 68 | if (this._focusWindow) return; 69 | 70 | this._focusWindow = 71 | global.display.get_focus_window() as Meta.Window | null; 72 | if (!this._focusWindow) return; 73 | 74 | tracker.confirmPinch( 75 | 0, 76 | [CloseWindowGestureState.PINCH_IN, CloseWindowGestureState.DEFAULT], 77 | CloseWindowGestureState.DEFAULT 78 | ); 79 | 80 | const frame = this._focusWindow.get_frame_rect(); 81 | this._preview.set_position(frame.x, frame.y); 82 | this._preview.set_size(frame.width, frame.height); 83 | 84 | // animate showing widget 85 | this._preview.opacity = 0; 86 | this._preview.show(); 87 | easeActor(this._preview, { 88 | opacity: 255, 89 | mode: Clutter.AnimationMode.EASE_OUT_QUAD, 90 | duration: WIGET_SHOWING_DURATION, 91 | }); 92 | } 93 | 94 | gestureUpdate(_tracker: unknown, progress: number): void { 95 | progress = CloseWindowGestureState.DEFAULT - progress; 96 | const scale = Util.lerp(1, END_SCALE, progress); 97 | this._preview.set_scale(scale, scale); 98 | this._preview.opacity = Util.lerp(255, END_OPACITY, progress); 99 | } 100 | 101 | gestureEnd( 102 | _tracker: unknown, 103 | duration: number, 104 | progress: CloseWindowGestureState 105 | ) { 106 | switch (progress) { 107 | case CloseWindowGestureState.DEFAULT: 108 | this._animatePreview(false, duration); 109 | break; 110 | case CloseWindowGestureState.PINCH_IN: 111 | this._animatePreview( 112 | true, 113 | duration, 114 | this._invokeGestureCompleteAction.bind(this) 115 | ); 116 | } 117 | } 118 | 119 | private _invokeGestureCompleteAction() { 120 | switch (this._closeType) { 121 | case PinchGestureType.CLOSE_WINDOW: 122 | this._focusWindow?.delete?.(global.get_current_time()); 123 | break; 124 | case PinchGestureType.CLOSE_DOCUMENT: 125 | this._keyboard.sendKeys([Clutter.KEY_Control_L, Clutter.KEY_w]); 126 | } 127 | } 128 | 129 | private _animatePreview( 130 | gestureCompleted: boolean, 131 | duration: number, 132 | callback?: () => void 133 | ) { 134 | easeActor(this._preview, { 135 | opacity: gestureCompleted ? END_OPACITY : 255, 136 | scaleX: gestureCompleted ? END_SCALE : 1, 137 | scaleY: gestureCompleted ? END_SCALE : 1, 138 | duration, 139 | mode: Clutter.AnimationMode.EASE_OUT_QUAD, 140 | onStopped: () => { 141 | if (callback) callback(); 142 | this._gestureAnimationDone(); 143 | }, 144 | }); 145 | } 146 | 147 | private _gestureAnimationDone() { 148 | this._preview.hide(); 149 | this._preview.opacity = 255; 150 | this._preview.set_scale(1, 1); 151 | 152 | this._focusWindow = undefined; 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /extension/src/pinchGestures/pinchTracker.ts: -------------------------------------------------------------------------------- 1 | import Clutter from 'gi://Clutter'; 2 | import GObject from 'gi://GObject'; 3 | import Meta from 'gi://Meta'; 4 | import Shell from 'gi://Shell'; 5 | import * as Main from 'resource:///org/gnome/shell/ui/main.js'; 6 | import {CustomEventType} from 'resource:///org/gnome/shell/ui/swipeTracker.js'; 7 | import {TouchpadConstants} from '../../constants.js'; 8 | 9 | const MIN_ANIMATION_DURATION = 100; 10 | const MAX_ANIMATION_DURATION = 400; 11 | 12 | // Derivative of easeOutCubic at t=0 13 | const DURATION_MULTIPLIER = 3; 14 | const ANIMATION_BASE_VELOCITY = 0.002; 15 | 16 | const EVENT_HISTORY_THRESHOLD_MS = 150; 17 | const DECELERATION_TOUCHPAD = 0.997; 18 | const VELOCITY_CURVE_THRESHOLD = 2; 19 | const DECELERATION_PARABOLA_MULTIPLIER = 0.35; 20 | 21 | declare type HisotyEvent = {time: number; delta: number}; 22 | 23 | class EventHistoryTracker { 24 | private _data: HisotyEvent[] = []; 25 | 26 | reset() { 27 | this._data = []; 28 | } 29 | 30 | trim(time: number) { 31 | const thresholdTime = time - EVENT_HISTORY_THRESHOLD_MS; 32 | const index = this._data.findIndex(r => r.time >= thresholdTime); 33 | this._data.splice(0, index); 34 | } 35 | 36 | append(time: number, delta: number) { 37 | this.trim(time); 38 | this._data.push({time, delta}); 39 | } 40 | 41 | calculateVelocity() { 42 | if (this._data.length < 2) return 0; 43 | 44 | const firstTime = this._data[0].time; 45 | const lastTime = this._data[this._data.length - 1].time; 46 | 47 | if (firstTime === lastTime) return 0; 48 | 49 | const totalDelta = this._data 50 | .slice(1) 51 | .map(a => a.delta) 52 | .reduce((a, b) => a + b); 53 | const period = lastTime - firstTime; 54 | 55 | return totalDelta / period; 56 | } 57 | } 58 | 59 | // define enum 60 | enum TouchpadState { 61 | NONE = 0, 62 | HANDLING = 1, 63 | IGNORED = 2, 64 | } 65 | 66 | enum GestureACKState { 67 | NONE = 0, 68 | PENDING_ACK = 1, 69 | ACKED = 2, 70 | } 71 | 72 | export const TouchpadPinchGesture = GObject.registerClass( 73 | { 74 | Properties: {}, 75 | Signals: { 76 | begin: {param_types: []}, 77 | update: {param_types: [GObject.TYPE_DOUBLE]}, 78 | end: {param_types: [GObject.TYPE_DOUBLE, GObject.TYPE_DOUBLE]}, 79 | }, 80 | }, 81 | class TouchpadPinchGesture extends GObject.Object { 82 | private _nfingers: number[]; 83 | private _allowedModes: Shell.ActionMode; 84 | private _state = TouchpadState.NONE; 85 | private _ackState = GestureACKState.NONE; 86 | private _checkAllowedGesture?: (event: CustomEventType) => boolean; 87 | private _stageCaptureEvent?: number; 88 | private _historyTracker: EventHistoryTracker; 89 | private _progress_scale = 1.0; 90 | private _snapPoints = [0, 1, 2]; 91 | public enabled = true; 92 | private _initialProgress = 0; 93 | PINCH_MULTIPLIER: number; 94 | 95 | constructor(params: { 96 | nfingers: number[]; 97 | allowedModes: Shell.ActionMode; 98 | checkAllowedGesture?: (event: CustomEventType) => boolean; 99 | pinchSpeed?: number; 100 | }) { 101 | super(); 102 | this._nfingers = params.nfingers; 103 | this._allowedModes = params.allowedModes; 104 | this._checkAllowedGesture = params.checkAllowedGesture; 105 | this._stageCaptureEvent = global.stage.connect( 106 | 'captured-event::touchpad', 107 | this._handleEvent.bind(this) 108 | ); 109 | 110 | this._historyTracker = new EventHistoryTracker(); 111 | this.PINCH_MULTIPLIER = 112 | TouchpadConstants.PINCH_MULTIPLIER * (params.pinchSpeed ?? 1.0); 113 | } 114 | 115 | _handleEvent( 116 | _actor: undefined | Clutter.Actor, 117 | event: CustomEventType 118 | ): boolean { 119 | if (event.type() !== Clutter.EventType.TOUCHPAD_PINCH) 120 | return Clutter.EVENT_PROPAGATE; 121 | 122 | const gesturePhase = event.get_gesture_phase(); 123 | 124 | if (gesturePhase === Clutter.TouchpadGesturePhase.BEGIN) { 125 | this._state = TouchpadState.NONE; 126 | this._historyTracker.reset(); 127 | } 128 | 129 | if (this._state === TouchpadState.IGNORED || !this.enabled) 130 | return Clutter.EVENT_PROPAGATE; 131 | 132 | if ( 133 | this._allowedModes !== Shell.ActionMode.ALL && 134 | (this._allowedModes & Main.actionMode) === 0 135 | ) { 136 | this._interrupt(); 137 | this._state = TouchpadState.IGNORED; 138 | return Clutter.EVENT_PROPAGATE; 139 | } 140 | 141 | if ( 142 | !this._nfingers.includes( 143 | event.get_touchpad_gesture_finger_count() 144 | ) 145 | ) { 146 | this._state = TouchpadState.IGNORED; 147 | return Clutter.EVENT_PROPAGATE; 148 | } 149 | 150 | if ( 151 | gesturePhase === Clutter.TouchpadGesturePhase.BEGIN && 152 | this._checkAllowedGesture !== undefined 153 | ) { 154 | try { 155 | if (this._checkAllowedGesture(event) !== true) { 156 | this._state = TouchpadState.IGNORED; 157 | return Clutter.EVENT_PROPAGATE; 158 | } 159 | } catch (ex) { 160 | this._state = TouchpadState.IGNORED; 161 | return Clutter.EVENT_PROPAGATE; 162 | } 163 | } 164 | 165 | this._state = TouchpadState.HANDLING; 166 | const time = event.get_time(); 167 | const pinch_scale = event.get_gesture_pinch_scale(); 168 | 169 | switch (gesturePhase) { 170 | case Clutter.TouchpadGesturePhase.BEGIN: 171 | // this._previous_scale = 1.0; 172 | this._emitBegin(); 173 | break; 174 | case Clutter.TouchpadGesturePhase.UPDATE: 175 | this._emitUpdate(time, pinch_scale); 176 | break; 177 | 178 | case Clutter.TouchpadGesturePhase.END: 179 | case Clutter.TouchpadGesturePhase.CANCEL: 180 | this._emitEnd(time); 181 | this._state = TouchpadState.NONE; 182 | this._historyTracker.reset(); 183 | break; 184 | } 185 | 186 | return Clutter.EVENT_STOP; 187 | } 188 | 189 | private _getBounds(): [number, number] { 190 | return [ 191 | this._snapPoints[0], 192 | this._snapPoints[this._snapPoints.length - 1], 193 | ]; 194 | } 195 | 196 | /** 197 | * @param currentProgress must be in increasing order 198 | */ 199 | public confirmPinch( 200 | _distance: number, 201 | snapPoints: number[], 202 | currentProgress: number 203 | ) { 204 | if (this._ackState !== GestureACKState.PENDING_ACK) return; 205 | 206 | this._snapPoints = snapPoints; 207 | this._initialProgress = currentProgress; 208 | this._progress_scale = Math.clamp( 209 | currentProgress, 210 | ...this._getBounds() 211 | ); 212 | this._ackState = GestureACKState.ACKED; 213 | } 214 | 215 | _reset() { 216 | this._historyTracker.reset(); 217 | 218 | this._snapPoints = []; 219 | this._initialProgress = 0; 220 | } 221 | 222 | private _interrupt() { 223 | if (this._ackState !== GestureACKState.ACKED) return; 224 | 225 | this._reset(); 226 | this._ackState = GestureACKState.NONE; 227 | this.emit('end', 0, this._initialProgress); 228 | } 229 | 230 | private _emitBegin() { 231 | if (this._ackState === GestureACKState.ACKED) return; 232 | this._historyTracker.reset(); 233 | this._ackState = GestureACKState.PENDING_ACK; 234 | this._progress_scale = 1.0; 235 | this.emit('begin'); 236 | } 237 | 238 | private _emitUpdate(time: number, pinch_scale: number) { 239 | if (this._ackState !== GestureACKState.ACKED) return; 240 | 241 | // this._historyTracker.append(time, delta); 242 | // delta /= this._pinchDistance; 243 | const new_progress = 244 | Math.log2(pinch_scale) * this.PINCH_MULTIPLIER + 245 | this._initialProgress; 246 | const delta = new_progress - this._progress_scale; 247 | this._historyTracker.append(time, delta); 248 | this._progress_scale = Math.clamp( 249 | new_progress, 250 | ...this._getBounds() 251 | ); 252 | 253 | // log(JSON.stringify({ pinch_scale, new_progress, delta })); 254 | this.emit('update', this._progress_scale); 255 | } 256 | 257 | private _emitEnd(time: number) { 258 | if (this._ackState !== GestureACKState.ACKED) return; 259 | 260 | this._historyTracker.trim(time); 261 | 262 | let velocity = this._historyTracker.calculateVelocity(); 263 | const endProgress = this._getEndProgress(velocity); 264 | 265 | if ((endProgress - this._progress_scale) * velocity <= 0) 266 | velocity = ANIMATION_BASE_VELOCITY; 267 | 268 | let duration = Math.abs( 269 | ((this._progress_scale - endProgress) / velocity) * 270 | DURATION_MULTIPLIER 271 | ); 272 | duration = Math.clamp( 273 | duration, 274 | MIN_ANIMATION_DURATION, 275 | MAX_ANIMATION_DURATION 276 | ); 277 | 278 | this._reset(); 279 | this._ackState = GestureACKState.NONE; 280 | this.emit('end', duration, endProgress); 281 | } 282 | 283 | private _findEndPoints() { 284 | const current = this._progress_scale; 285 | return { 286 | current, 287 | next: Math.clamp(Math.ceil(current), ...this._getBounds()), 288 | previous: Math.clamp(Math.floor(current), ...this._getBounds()), 289 | }; 290 | } 291 | 292 | private _findClosestPoint(pos: number) { 293 | const distances = this._snapPoints.map(x => Math.abs(x - pos)); 294 | const min = Math.min(...distances); 295 | return distances.indexOf(min); 296 | } 297 | 298 | private _findNextPoint(pos: number) { 299 | return this._snapPoints.findIndex(p => p >= pos); 300 | } 301 | 302 | private _findPreviousPoint(pos: number) { 303 | const reversedIndex = this._snapPoints 304 | .slice() 305 | .reverse() 306 | .findIndex(p => p <= pos); 307 | return this._snapPoints.length - 1 - reversedIndex; 308 | } 309 | 310 | private _findPointForProjection(pos: number, velocity: number) { 311 | const initial = this._findClosestPoint(this._initialProgress); 312 | const prev = this._findPreviousPoint(pos); 313 | const next = this._findNextPoint(pos); 314 | 315 | if ((velocity > 0 ? prev : next) === initial) 316 | return velocity > 0 ? next : prev; 317 | 318 | return this._findClosestPoint(pos); 319 | } 320 | 321 | private _getEndProgress(velocity: number) { 322 | // if (Math.abs(velocity) < VELOCITY_THRESHOLD_TOUCHPAD) 323 | // return this._snapPoints[this._findClosestPoint(this._progress)]; 324 | 325 | const slope = 326 | DECELERATION_TOUCHPAD / (1.0 - DECELERATION_TOUCHPAD) / 1000.0; 327 | 328 | let pos; 329 | 330 | if (Math.abs(velocity) > VELOCITY_CURVE_THRESHOLD) { 331 | const c = slope / 2 / DECELERATION_PARABOLA_MULTIPLIER; 332 | const x = Math.abs(velocity) - VELOCITY_CURVE_THRESHOLD + c; 333 | 334 | pos = 335 | slope * VELOCITY_CURVE_THRESHOLD + 336 | DECELERATION_PARABOLA_MULTIPLIER * x * x - 337 | DECELERATION_PARABOLA_MULTIPLIER * c * c; 338 | } else { 339 | pos = Math.abs(velocity) * slope; 340 | } 341 | 342 | pos = pos * Math.sign(velocity) + this._progress_scale; 343 | pos = Math.clamp(pos, ...this._getBounds()); 344 | 345 | const index = this._findPointForProjection(pos, velocity); 346 | 347 | return this._snapPoints[index]; 348 | } 349 | 350 | destroy() { 351 | if (this._stageCaptureEvent) { 352 | global.stage.disconnect(this._stageCaptureEvent); 353 | this._stageCaptureEvent = 0; 354 | } 355 | } 356 | } 357 | ); 358 | -------------------------------------------------------------------------------- /extension/src/pinchGestures/showDesktop.ts: -------------------------------------------------------------------------------- 1 | import Clutter from 'gi://Clutter'; 2 | import GObject from 'gi://GObject'; 3 | import Meta from 'gi://Meta'; 4 | import Shell from 'gi://Shell'; 5 | import * as Main from 'resource:///org/gnome/shell/ui/main.js'; 6 | import {lerp} from 'resource:///org/gnome/shell/misc/util.js'; 7 | import {TouchpadPinchGesture} from './pinchTracker.js'; 8 | import {easeActor} from '../utils/environment.js'; 9 | import { 10 | MonitorConstraint, 11 | Monitor, 12 | } from 'resource:///org/gnome/shell/ui/layout.js'; 13 | 14 | // declare enum 15 | enum WorkspaceManagerState { 16 | DEFAULT = 0, 17 | SHOW_DESKTOP = 1, 18 | } 19 | 20 | // declare enum 21 | enum ExtensionState { 22 | DEFAULT, 23 | ANIMATING, 24 | } 25 | 26 | declare type Type_TouchpadPinchGesture = typeof TouchpadPinchGesture.prototype; 27 | 28 | declare type CornerPositions = 29 | | 'top-left' 30 | | 'top-mid' 31 | | 'top-right' 32 | | 'bottom-left' 33 | | 'bottom-mid' 34 | | 'bottom-right'; 35 | 36 | declare type Point = { 37 | x: number; 38 | y: number; 39 | }; 40 | 41 | declare type Corner = Point & { 42 | position: CornerPositions; 43 | }; 44 | 45 | declare type WindowActorClone = { 46 | windowActor: Meta.WindowActor; 47 | clone: Clutter.Clone; 48 | translation?: { 49 | start: Point; 50 | end: Point; 51 | }; 52 | }; 53 | 54 | class MonitorGroup { 55 | public monitor: Monitor; 56 | private _container: Clutter.Actor; 57 | private _windowActorClones: WindowActorClone[] = []; 58 | private _corners: Corner[]; 59 | private _bottomMidCorner: Corner; 60 | 61 | constructor(monitor: Monitor) { 62 | this.monitor = monitor; 63 | 64 | this._container = new Clutter.Actor({visible: false}); 65 | const constraint = new MonitorConstraint({index: monitor.index}); 66 | this._container.add_constraint(constraint); 67 | 68 | this._bottomMidCorner = { 69 | x: this.monitor.width / 2, 70 | y: this.monitor.height, 71 | position: 'bottom-mid', 72 | }; 73 | this._corners = [ 74 | {x: 0, y: 0, position: 'top-left'}, 75 | 76 | // { x: this.monitor.width / 2, y: 0, position: 'top-mid' }, 77 | {x: this.monitor.width, y: 0, position: 'top-right'}, 78 | { 79 | x: this.monitor.width, 80 | y: this.monitor.height, 81 | position: 'bottom-right', 82 | }, 83 | 84 | // { x: this.monitor.width / 2, y: this.monitor.height, position: 'bottom-mid' }, 85 | {x: 0, y: this.monitor.height, position: 'bottom-left'}, 86 | ]; 87 | 88 | this._container.set_clip_to_allocation(true); 89 | Main.layoutManager.uiGroup.insert_child_above( 90 | this._container, 91 | global.window_group 92 | ); 93 | } 94 | 95 | _addWindowActor(windowActor: Meta.WindowActor) { 96 | const clone = new Clutter.Clone({ 97 | source: windowActor, 98 | x: windowActor.x - this.monitor.x, 99 | y: windowActor.y - this.monitor.y, 100 | }); 101 | 102 | // windowActor.opacity = 0; 103 | windowActor.hide(); 104 | 105 | this._windowActorClones.push({clone, windowActor}); 106 | this._container.insert_child_below(clone, null); 107 | } 108 | 109 | private _getDestPoint(clone: Clutter.Clone, destCorner: Corner): Point { 110 | const destY = destCorner.y; 111 | const cloneRelXCenter = Math.round(clone.width / 2); 112 | 113 | switch (destCorner.position) { 114 | case 'top-left': 115 | return {x: destCorner.x - clone.width, y: destY - clone.height}; 116 | case 'top-mid': 117 | return { 118 | x: destCorner.x - cloneRelXCenter, 119 | y: destY - clone.height, 120 | }; 121 | case 'top-right': 122 | return {x: destCorner.x, y: destY - clone.height}; 123 | case 'bottom-right': 124 | return {x: destCorner.x, y: destY}; 125 | case 'bottom-mid': 126 | return {x: destCorner.x - cloneRelXCenter, y: destY}; 127 | case 'bottom-left': 128 | return {x: destCorner.x - clone.width, y: destY}; 129 | } 130 | } 131 | 132 | private _calculateDist(p: Point, q: Point) { 133 | return Math.abs(p.x - q.x) + Math.abs(p.y - q.y); 134 | } 135 | 136 | private _assignCorner(actorClone: WindowActorClone, corner: Corner) { 137 | const {clone} = actorClone; 138 | const destPoint = this._getDestPoint(clone, corner); 139 | actorClone.translation = { 140 | start: {x: clone.x, y: clone.y}, 141 | end: {x: destPoint.x, y: destPoint.y}, 142 | }; 143 | } 144 | 145 | private _fillCloneDestPosition(windowActorsClones: WindowActorClone[]) { 146 | if (windowActorsClones.length === 0) return; 147 | 148 | if (windowActorsClones.length === 1) { 149 | this._assignCorner(windowActorsClones[0], this._bottomMidCorner); 150 | return; 151 | } 152 | 153 | interface IMetricData { 154 | value: number; 155 | actorClone: WindowActorClone; 156 | corner: Corner; 157 | } 158 | 159 | const distanceMetrics: IMetricData[] = []; 160 | this._corners.forEach(corner => { 161 | windowActorsClones.forEach(actorClone => { 162 | distanceMetrics.push({ 163 | value: this._calculateDist( 164 | actorClone.clone, 165 | this._getDestPoint(actorClone.clone, corner) 166 | ), 167 | actorClone, 168 | corner, 169 | }); 170 | }); 171 | }); 172 | 173 | const minActorsPerCorner = Math.floor( 174 | windowActorsClones.length / this._corners.length 175 | ); 176 | let extraActors = 177 | windowActorsClones.length - 178 | this._corners.length * minActorsPerCorner; 179 | const clusterSizes = new Map(); 180 | const takenActorClones = new Set(); 181 | distanceMetrics.sort((a, b) => a.value - b.value); 182 | distanceMetrics.forEach(metric => { 183 | const size = clusterSizes.get(metric.corner.position) ?? 0; 184 | if (takenActorClones.has(metric.actorClone)) return; 185 | 186 | if (size >= minActorsPerCorner) { 187 | if (size > minActorsPerCorner || extraActors <= 0) return; 188 | extraActors -= 1; 189 | } 190 | 191 | takenActorClones.add(metric.actorClone); 192 | clusterSizes.set(metric.corner.position, size + 1); 193 | 194 | this._assignCorner(metric.actorClone, metric.corner); 195 | }); 196 | } 197 | 198 | gestureBegin(windowActors: Meta.WindowActor[]) { 199 | windowActors.forEach(this._addWindowActor.bind(this)); 200 | this._fillCloneDestPosition(this._windowActorClones); 201 | this._container.show(); 202 | } 203 | 204 | gestureUpdate(progress: number) { 205 | this._windowActorClones.forEach(actorClone => { 206 | const {clone, translation} = actorClone; 207 | if (translation === undefined) return; 208 | clone.x = lerp(translation.start.x, translation.end.x, progress); 209 | clone.y = lerp(translation.start.y, translation.end.y, progress); 210 | clone.opacity = lerp(255, 128, progress); 211 | }); 212 | } 213 | 214 | gestureEnd(progress: WorkspaceManagerState, duration: number) { 215 | this._windowActorClones.forEach(actorClone => { 216 | const {clone, translation, windowActor} = actorClone; 217 | 218 | if (translation === undefined) { 219 | clone.destroy(); 220 | return; 221 | } 222 | 223 | easeActor(clone, { 224 | x: lerp(translation.start.x, translation.end.x, progress), 225 | y: lerp(translation.start.y, translation.end.y, progress), 226 | opacity: lerp(255, 128, progress), 227 | mode: Clutter.AnimationMode.EASE_OUT_QUAD, 228 | duration, 229 | onStopped: () => { 230 | this._container.hide(); 231 | 232 | const window = 233 | windowActor.meta_window as Meta.Window | null; 234 | 235 | if (window?.can_minimize()) { 236 | Main.wm.skipNextEffect(windowActor); 237 | 238 | if (progress === WorkspaceManagerState.DEFAULT) { 239 | window.unminimize(); 240 | windowActor.show(); 241 | } else { 242 | window.minimize(); 243 | windowActor.hide(); 244 | } 245 | } else { 246 | windowActor.show(); 247 | } 248 | 249 | clone.destroy(); 250 | }, 251 | }); 252 | }); 253 | 254 | if (this._windowActorClones.length === 0) this._container.hide(); 255 | 256 | this._windowActorClones = []; 257 | } 258 | 259 | destroy() { 260 | this._container.destroy(); 261 | } 262 | } 263 | 264 | export class ShowDesktopExtension implements ISubExtension { 265 | private _windows = new Set(); 266 | private _workspace?: Meta.Workspace; 267 | private _workspaceChangedId = 0; 268 | private _windowAddedId = 0; 269 | private _windowRemovedId = 0; 270 | private _windowUnMinimizedId = 0; 271 | private _monitorChangedId = 0; 272 | private _extensionState = ExtensionState.DEFAULT; 273 | private _minimizingWindows: Meta.Window[] = []; 274 | private _workspaceManagerState = WorkspaceManagerState.DEFAULT; 275 | private _monitorGroups: MonitorGroup[] = []; 276 | private _pinchTracker: Type_TouchpadPinchGesture; 277 | 278 | constructor(nfingers: number[]) { 279 | this._pinchTracker = new TouchpadPinchGesture({ 280 | nfingers: nfingers, 281 | allowedModes: Shell.ActionMode.NORMAL, 282 | }); 283 | } 284 | 285 | apply(): void { 286 | this._pinchTracker.connect('begin', this.gestureBegin.bind(this)); 287 | this._pinchTracker.connect('update', this.gestureUpdate.bind(this)); 288 | this._pinchTracker.connect('end', this.gestureEnd.bind(this)); 289 | 290 | for (const monitor of Main.layoutManager.monitors) 291 | this._monitorGroups.push(new MonitorGroup(monitor)); 292 | 293 | this._workspaceChangedId = global.workspace_manager.connect( 294 | 'active-workspace-changed', 295 | this._workspaceChanged.bind(this) 296 | ); 297 | this._workspaceChanged(); 298 | this._windowUnMinimizedId = global.window_manager.connect( 299 | 'unminimize', 300 | this._windowUnMinimized.bind(this) 301 | ); 302 | 303 | this._monitorChangedId = Main.layoutManager.connect( 304 | 'monitors-changed', 305 | () => { 306 | this._monitorGroups.forEach(m => m.destroy()); 307 | this._monitorGroups = []; 308 | for (const monitor of Main.layoutManager.monitors) 309 | this._monitorGroups.push(new MonitorGroup(monitor)); 310 | } 311 | ); 312 | } 313 | 314 | destroy(): void { 315 | this._pinchTracker?.destroy(); 316 | 317 | if (this._monitorChangedId) 318 | Main.layoutManager.disconnect(this._monitorChangedId); 319 | 320 | if (this._windowAddedId) 321 | this._workspace?.disconnect(this._windowAddedId); 322 | 323 | if (this._windowRemovedId) 324 | this._workspace?.disconnect(this._windowRemovedId); 325 | 326 | if (this._workspaceChangedId) 327 | global.workspace_manager.disconnect(this._workspaceChangedId); 328 | 329 | if (this._windowUnMinimizedId) 330 | global.window_manager.disconnect(this._windowUnMinimizedId); 331 | 332 | this._resetState(); 333 | 334 | for (const monitor of this._monitorGroups) monitor.destroy(); 335 | this._monitorGroups = []; 336 | } 337 | 338 | private _getMinimizableWindows() { 339 | if (this._workspaceManagerState === WorkspaceManagerState.DEFAULT) { 340 | this._minimizingWindows = global 341 | .get_window_actors() 342 | .filter(a => a.visible) 343 | 344 | // top actors should be at the beginning 345 | .reverse() 346 | .map(actor => actor.meta_window) 347 | .filter( 348 | win => 349 | win.get_window_type() !== Meta.WindowType.DESKTOP && 350 | this._windows.has(win) && 351 | (win.is_always_on_all_workspaces() || 352 | win.get_workspace().index === 353 | this._workspace?.index) && 354 | !win.minimized 355 | ); 356 | } 357 | 358 | return this._minimizingWindows; 359 | } 360 | 361 | gestureBegin(tracker: Type_TouchpadPinchGesture) { 362 | this._extensionState = ExtensionState.ANIMATING; 363 | 364 | this._minimizingWindows = this._getMinimizableWindows(); 365 | 366 | for (const monitor of this._monitorGroups) { 367 | const windowActors = this._minimizingWindows 368 | .map(win => win.get_compositor_private()) 369 | .filter((actor: GObject.Object): actor is Meta.WindowActor => { 370 | return ( 371 | actor instanceof Meta.WindowActor && 372 | actor.meta_window.get_monitor() === 373 | monitor.monitor.index 374 | ); 375 | }); 376 | 377 | monitor.gestureBegin(windowActors); 378 | } 379 | 380 | tracker.confirmPinch( 381 | 1, 382 | [WorkspaceManagerState.DEFAULT, WorkspaceManagerState.SHOW_DESKTOP], 383 | this._workspaceManagerState 384 | ); 385 | } 386 | 387 | gestureUpdate(_tracker: unknown, progress: number) { 388 | // progress 0 -> NORMAL state, 1 -> SHOW Desktop 389 | for (const monitor of this._monitorGroups) 390 | monitor.gestureUpdate(progress); 391 | } 392 | 393 | gestureEnd(_tracker: unknown, duration: number, endProgress: number) { 394 | // endProgress 0 -> NORMAL state, 1 -> SHOW Desktop 395 | for (const monitor of this._monitorGroups) 396 | monitor.gestureEnd(endProgress, duration); 397 | 398 | if (endProgress === WorkspaceManagerState.DEFAULT) 399 | this._minimizingWindows = []; 400 | 401 | this._extensionState = ExtensionState.DEFAULT; 402 | this._workspaceManagerState = endProgress; 403 | } 404 | 405 | private _resetState(animate = false) { 406 | // reset state, aka. undo show desktop 407 | this._minimizingWindows.forEach(win => { 408 | if (!this._windows.has(win)) return; 409 | 410 | const onStopped = () => { 411 | Main.wm.skipNextEffect(win.get_compositor_private()); 412 | win.unminimize(); 413 | }; 414 | 415 | const actor = win.get_compositor_private() as Meta.WindowActor; 416 | if (animate && actor) { 417 | actor.show(); 418 | actor.opacity = 0; 419 | easeActor(actor, { 420 | opacity: 255, 421 | duration: 500, 422 | mode: Clutter.AnimationMode.EASE_OUT_QUAD, 423 | onStopped, 424 | }); 425 | } else onStopped(); 426 | }); 427 | 428 | this._minimizingWindows = []; 429 | this._workspaceManagerState = WorkspaceManagerState.DEFAULT; 430 | } 431 | 432 | private _workspaceChanged() { 433 | if (this._windowAddedId) 434 | this._workspace?.disconnect(this._windowAddedId); 435 | 436 | if (this._windowRemovedId) 437 | this._workspace?.disconnect(this._windowRemovedId); 438 | 439 | this._resetState(false); 440 | this._windows.clear(); 441 | this._workspace = global.workspace_manager.get_active_workspace(); 442 | 443 | this._windowAddedId = this._workspace.connect( 444 | 'window-added', 445 | this._windowAdded.bind(this) 446 | ); 447 | this._windowRemovedId = this._workspace.connect( 448 | 'window-removed', 449 | this._windowRemoved.bind(this) 450 | ); 451 | this._workspace 452 | .list_windows() 453 | .forEach(win => this._windowAdded(this._workspace, win)); 454 | } 455 | 456 | private _windowAdded(_workspace: unknown, window: Meta.Window) { 457 | if (this._windows.has(window)) return; 458 | 459 | if ( 460 | !window.skip_taskbar && 461 | this._extensionState === ExtensionState.DEFAULT 462 | ) 463 | this._resetState(true); 464 | this._windows.add(window); 465 | } 466 | 467 | private _windowRemoved(_workspace: unknown, window: Meta.Window) { 468 | if (!this._windows.has(window)) return; 469 | this._windows.delete(window); 470 | } 471 | 472 | private _windowUnMinimized(_wm: Shell.WM, actor: Meta.WindowActor) { 473 | if (actor.meta_window.get_workspace().index !== this._workspace?.index) 474 | return; 475 | 476 | this._minimizingWindows = []; 477 | this._workspaceManagerState = WorkspaceManagerState.DEFAULT; 478 | } 479 | } 480 | -------------------------------------------------------------------------------- /extension/src/swipeTracker.ts: -------------------------------------------------------------------------------- 1 | import Clutter from 'gi://Clutter'; 2 | import GObject from 'gi://GObject'; 3 | import Shell from 'gi://Shell'; 4 | import {actionMode} from 'resource:///org/gnome/shell/ui/main.js'; 5 | import { 6 | SwipeTracker, 7 | CustomEventType, 8 | } from 'resource:///org/gnome/shell/ui/swipeTracker.js'; 9 | import {TouchpadConstants} from '../constants.js'; 10 | 11 | enum TouchpadState { 12 | NONE = 0, 13 | PENDING = 1, 14 | HANDLING = 2, 15 | IGNORED = 3, 16 | } 17 | 18 | export const TouchpadSwipeGesture = GObject.registerClass( 19 | { 20 | Properties: { 21 | enabled: GObject.ParamSpec.boolean( 22 | 'enabled', 23 | 'enabled', 24 | 'enabled', 25 | GObject.ParamFlags.READWRITE, 26 | true 27 | ), 28 | orientation: GObject.ParamSpec.enum( 29 | 'orientation', 30 | 'orientation', 31 | 'orientation', 32 | GObject.ParamFlags.READWRITE, 33 | Clutter.Orientation, 34 | Clutter.Orientation.HORIZONTAL 35 | ), 36 | }, 37 | Signals: { 38 | begin: { 39 | param_types: [ 40 | GObject.TYPE_UINT, 41 | GObject.TYPE_DOUBLE, 42 | GObject.TYPE_DOUBLE, 43 | ], 44 | }, 45 | update: { 46 | param_types: [ 47 | GObject.TYPE_UINT, 48 | GObject.TYPE_DOUBLE, 49 | GObject.TYPE_DOUBLE, 50 | ], 51 | }, 52 | end: {param_types: [GObject.TYPE_UINT, GObject.TYPE_DOUBLE]}, 53 | }, 54 | }, 55 | class TouchpadSwipeGesture extends GObject.Object { 56 | private _nfingers: number[]; 57 | private _allowedModes: Shell.ActionMode; 58 | orientation: Clutter.Orientation; 59 | private _checkAllowedGesture?: (event: CustomEventType) => boolean; 60 | private _cumulativeX = 0; 61 | private _cumulativeY = 0; 62 | private _followNaturalScroll: boolean; 63 | _stageCaptureEvent = 0; 64 | SWIPE_MULTIPLIER: number; 65 | TOUCHPAD_BASE_HEIGHT = TouchpadConstants.TOUCHPAD_BASE_HEIGHT; 66 | TOUCHPAD_BASE_WIDTH = TouchpadConstants.TOUCHPAD_BASE_WIDTH; 67 | DRAG_THRESHOLD_DISTANCE = TouchpadConstants.DRAG_THRESHOLD_DISTANCE; 68 | enabled = true; 69 | private _state = TouchpadState.NONE; 70 | private _toggledDirection = false; 71 | private _swipeGestureBeginTime = 0; 72 | private _holdGestureBeginTime = 0; 73 | private _holdGestureCancelTime = 0; 74 | 75 | constructor( 76 | nfingers: number[], 77 | allowedModes: Shell.ActionMode, 78 | orientation: Clutter.Orientation, 79 | followNaturalScroll = true, 80 | checkAllowedGesture?: (event: CustomEventType) => boolean, 81 | gestureSpeed = 1.0 82 | ) { 83 | super(); 84 | this._nfingers = nfingers; 85 | this._allowedModes = allowedModes; 86 | this.orientation = orientation; 87 | this._checkAllowedGesture = checkAllowedGesture; 88 | this._followNaturalScroll = followNaturalScroll; 89 | 90 | this._stageCaptureEvent = global.stage.connect( 91 | 'captured-event::touchpad', 92 | this._handleEvent.bind(this) 93 | ); 94 | 95 | this.SWIPE_MULTIPLIER = 96 | TouchpadConstants.SWIPE_MULTIPLIER * 97 | (typeof gestureSpeed !== 'number' ? 1.0 : gestureSpeed); 98 | } 99 | 100 | private _resetState() { 101 | this._state = TouchpadState.NONE; 102 | this._toggledDirection = false; 103 | 104 | this._swipeGestureBeginTime = 0; 105 | this._holdGestureBeginTime = 0; 106 | this._holdGestureCancelTime = 0; 107 | } 108 | 109 | _handleEvent( 110 | _actor: undefined | Clutter.Actor, 111 | event: CustomEventType 112 | ): boolean { 113 | if (event.type() === Clutter.EventType.TOUCHPAD_HOLD) { 114 | this._handleHoldEvent(event); 115 | return Clutter.EVENT_PROPAGATE; 116 | } 117 | 118 | if (event.type() !== Clutter.EventType.TOUCHPAD_SWIPE) 119 | return Clutter.EVENT_PROPAGATE; 120 | 121 | const gesturePhase = event.get_gesture_phase(); 122 | 123 | if (gesturePhase === Clutter.TouchpadGesturePhase.BEGIN) { 124 | this._swipeGestureBeginTime = event.get_time(); 125 | this._state = TouchpadState.NONE; 126 | this._toggledDirection = false; 127 | } 128 | 129 | if (this._state === TouchpadState.IGNORED) 130 | return Clutter.EVENT_PROPAGATE; 131 | 132 | if (!this.enabled) return Clutter.EVENT_PROPAGATE; 133 | 134 | if ( 135 | this._allowedModes !== Shell.ActionMode.ALL && 136 | (this._allowedModes & actionMode) === 0 137 | ) { 138 | this._state = TouchpadState.IGNORED; 139 | return Clutter.EVENT_PROPAGATE; 140 | } 141 | 142 | if ( 143 | !this._nfingers.includes( 144 | event.get_touchpad_gesture_finger_count() 145 | ) 146 | ) { 147 | this._state = TouchpadState.IGNORED; 148 | return Clutter.EVENT_PROPAGATE; 149 | } 150 | 151 | if ( 152 | gesturePhase === Clutter.TouchpadGesturePhase.BEGIN && 153 | this._checkAllowedGesture !== undefined 154 | ) { 155 | try { 156 | if (this._checkAllowedGesture(event) !== true) { 157 | this._state = TouchpadState.IGNORED; 158 | return Clutter.EVENT_PROPAGATE; 159 | } 160 | } catch (ex) { 161 | this._state = TouchpadState.IGNORED; 162 | return Clutter.EVENT_PROPAGATE; 163 | } 164 | } 165 | 166 | const time = event.get_time(); 167 | 168 | const [x, y] = event.get_coords(); 169 | const [dx, dy] = event.get_gesture_motion_delta_unaccelerated() as [ 170 | number, 171 | number, 172 | ]; 173 | 174 | if (this._state === TouchpadState.NONE) { 175 | if (dx === 0 && dy === 0) return Clutter.EVENT_PROPAGATE; 176 | 177 | this._cumulativeX = 0; 178 | this._cumulativeY = 0; 179 | this._state = TouchpadState.PENDING; 180 | } 181 | 182 | if (this._state === TouchpadState.PENDING) { 183 | this._cumulativeX += dx * this.SWIPE_MULTIPLIER; 184 | this._cumulativeY += dy * this.SWIPE_MULTIPLIER; 185 | 186 | const cdx = this._cumulativeX; 187 | const cdy = this._cumulativeY; 188 | const distance = Math.sqrt(cdx * cdx + cdy * cdy); 189 | 190 | if (distance >= this.DRAG_THRESHOLD_DISTANCE) { 191 | const gestureOrientation = 192 | Math.abs(cdx) > Math.abs(cdy) 193 | ? Clutter.Orientation.HORIZONTAL 194 | : Clutter.Orientation.VERTICAL; 195 | 196 | this._cumulativeX = 0; 197 | this._cumulativeY = 0; 198 | 199 | if (gestureOrientation === this.orientation) { 200 | this._state = TouchpadState.HANDLING; 201 | this.emit('begin', time, x, y); 202 | } else { 203 | this._state = TouchpadState.IGNORED; 204 | return Clutter.EVENT_PROPAGATE; 205 | } 206 | } else { 207 | return Clutter.EVENT_PROPAGATE; 208 | } 209 | } 210 | 211 | const vertical = this.orientation === Clutter.Orientation.VERTICAL; 212 | let delta = 213 | (vertical !== this._toggledDirection ? dy : dx) * 214 | this.SWIPE_MULTIPLIER; 215 | const distance = vertical 216 | ? this.TOUCHPAD_BASE_HEIGHT 217 | : this.TOUCHPAD_BASE_WIDTH; 218 | 219 | switch (gesturePhase) { 220 | case Clutter.TouchpadGesturePhase.BEGIN: 221 | case Clutter.TouchpadGesturePhase.UPDATE: 222 | if (this._followNaturalScroll) delta = -delta; 223 | 224 | this.emit('update', time, delta, distance); 225 | break; 226 | 227 | case Clutter.TouchpadGesturePhase.END: 228 | case Clutter.TouchpadGesturePhase.CANCEL: 229 | this.emit('end', time, distance); 230 | this._resetState(); 231 | break; 232 | } 233 | 234 | return this._state === TouchpadState.HANDLING 235 | ? Clutter.EVENT_STOP 236 | : Clutter.EVENT_PROPAGATE; 237 | } 238 | 239 | private _handleHoldEvent(event: CustomEventType) { 240 | switch (event.get_gesture_phase()) { 241 | case Clutter.TouchpadGesturePhase.BEGIN: 242 | this._holdGestureCancelTime = 0; 243 | this._holdGestureBeginTime = event.get_time(); 244 | break; 245 | case Clutter.TouchpadGesturePhase.CANCEL: 246 | this._holdGestureCancelTime = event.get_time(); 247 | break; 248 | case Clutter.TouchpadGesturePhase.END: 249 | this._holdGestureBeginTime = 0; 250 | this._holdGestureCancelTime = 0; 251 | } 252 | } 253 | 254 | isItHoldAndSwipeGesture() { 255 | if (this._holdGestureCancelTime === 0) return false; 256 | 257 | return ( 258 | this._holdGestureCancelTime - this._holdGestureBeginTime >= 259 | TouchpadConstants.HOLD_SWIPE_DELAY_DURATION && 260 | this._swipeGestureBeginTime - this._holdGestureCancelTime <= 261 | Math.max(100, TouchpadConstants.HOLD_SWIPE_DELAY_DURATION) 262 | ); 263 | } 264 | 265 | switchDirectionTo(direction: Clutter.Orientation): void { 266 | if (this._state !== TouchpadState.HANDLING) return; 267 | 268 | this._toggledDirection = direction !== this.orientation; 269 | } 270 | 271 | destroy() { 272 | if (this._stageCaptureEvent) { 273 | global.stage.disconnect(this._stageCaptureEvent); 274 | this._stageCaptureEvent = 0; 275 | } 276 | } 277 | } 278 | ); 279 | 280 | declare type _SwipeTrackerOptionalParams = { 281 | allowTouch?: boolean; 282 | allowDrag?: boolean; 283 | allowScroll?: boolean; 284 | }; 285 | 286 | /** 287 | * 288 | * @param actor 289 | * @param nfingers 290 | * @param allowedModes 291 | * @param orientation 292 | * @param followNaturalScroll 293 | * @param gestureSpeed 294 | * @param params 295 | */ 296 | export function createSwipeTracker( 297 | actor: Clutter.Actor, 298 | nfingers: number[], 299 | allowedModes: Shell.ActionMode, 300 | orientation: Clutter.Orientation, 301 | followNaturalScroll = true, 302 | gestureSpeed = 1, 303 | params?: _SwipeTrackerOptionalParams 304 | ): typeof SwipeTracker.prototype { 305 | params = params ?? {}; 306 | params.allowDrag = params.allowDrag ?? false; 307 | params.allowScroll = params.allowScroll ?? false; 308 | const allowTouch = params.allowTouch ?? true; 309 | delete params.allowTouch; 310 | 311 | // create swipeTracker 312 | const swipeTracker = new SwipeTracker( 313 | actor, 314 | orientation, 315 | allowedModes, 316 | params 317 | ); 318 | 319 | // remove touch gestures 320 | if (!allowTouch && swipeTracker._touchGesture) { 321 | global.stage.remove_action(swipeTracker._touchGesture); 322 | delete swipeTracker._touchGesture; 323 | } 324 | 325 | // remove old touchpad gesture from swipeTracker 326 | if (swipeTracker._touchpadGesture) { 327 | swipeTracker._touchpadGesture.destroy(); 328 | swipeTracker._touchpadGesture = undefined; 329 | } 330 | 331 | // add touchpadBindings to tracker 332 | swipeTracker._touchpadGesture = new TouchpadSwipeGesture( 333 | nfingers, 334 | swipeTracker._allowedModes, 335 | swipeTracker.orientation, 336 | followNaturalScroll, 337 | undefined, 338 | gestureSpeed 339 | ); 340 | 341 | swipeTracker._touchpadGesture.connect( 342 | 'begin', 343 | swipeTracker._beginGesture.bind(swipeTracker) 344 | ); 345 | swipeTracker._touchpadGesture.connect( 346 | 'update', 347 | swipeTracker._updateGesture.bind(swipeTracker) 348 | ); 349 | swipeTracker._touchpadGesture.connect( 350 | 'end', 351 | swipeTracker._endTouchpadGesture.bind(swipeTracker) 352 | ); 353 | swipeTracker.bind_property( 354 | 'enabled', 355 | swipeTracker._touchpadGesture, 356 | 'enabled', 357 | 0 358 | ); 359 | swipeTracker.bind_property( 360 | 'orientation', 361 | swipeTracker._touchpadGesture, 362 | 'orientation', 363 | GObject.BindingFlags.SYNC_CREATE 364 | ); 365 | 366 | return swipeTracker; 367 | } 368 | -------------------------------------------------------------------------------- /extension/src/utils/environment.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import Clutter from 'gi://Clutter'; 3 | import GObject from 'gi://GObject'; 4 | import St from 'gi://St'; 5 | 6 | declare type EaseParamsType = { 7 | duration: number; 8 | mode: Clutter.AnimationMode; 9 | repeatCount?: number; 10 | autoReverse?: boolean; 11 | onStopped?: (isFinished?: boolean) => void; 12 | } & {[P in KeysOfType]?: number}; 13 | 14 | /** 15 | * 16 | * @param actor 17 | * @param params 18 | */ 19 | export function easeActor( 20 | actor: T, 21 | params: EaseParamsType 22 | ): void { 23 | (actor as any).ease(params); 24 | } 25 | 26 | /** 27 | * 28 | * @param actor 29 | * @param value 30 | * @param params 31 | */ 32 | export function easeAdjustment( 33 | actor: St.Adjustment, 34 | value: number, 35 | params: EaseParamsType 36 | ): void { 37 | (actor as any).ease(value, params); 38 | } 39 | -------------------------------------------------------------------------------- /extension/src/utils/keyboard.ts: -------------------------------------------------------------------------------- 1 | import Clutter from 'gi://Clutter'; 2 | import GLib from 'gi://GLib'; 3 | 4 | const DELAY_BETWEEN_KEY_PRESS = 10; // ms 5 | const timeoutIds = new Set(); 6 | 7 | class VirtualKeyboard { 8 | private _virtualDevice: Clutter.VirtualInputDevice; 9 | 10 | constructor() { 11 | const seat = Clutter.get_default_backend().get_default_seat(); 12 | this._virtualDevice = seat.create_virtual_device( 13 | Clutter.InputDeviceType.KEYBOARD_DEVICE 14 | ); 15 | } 16 | 17 | sendKeys(keys: number[]) { 18 | // log(`sending keys: ${keys}`); 19 | 20 | const keyEvents: [number, Clutter.KeyState][] = []; 21 | keys.forEach(key => keyEvents.push([key, Clutter.KeyState.RELEASED])); 22 | keys.reverse().forEach(key => 23 | keyEvents.push([key, Clutter.KeyState.PRESSED]) 24 | ); 25 | 26 | let timeoutId = GLib.timeout_add( 27 | GLib.PRIORITY_DEFAULT, 28 | DELAY_BETWEEN_KEY_PRESS, 29 | () => { 30 | const keyEvent = keyEvents.pop(); 31 | if (keyEvent !== undefined) this._sendKey(...keyEvent); 32 | 33 | if (keyEvents.length === 0) { 34 | timeoutIds.delete(timeoutId); 35 | timeoutId = 0; 36 | return GLib.SOURCE_REMOVE; 37 | } 38 | 39 | return GLib.SOURCE_CONTINUE; 40 | } 41 | ); 42 | 43 | if (timeoutId) timeoutIds.add(timeoutId); 44 | } 45 | 46 | private _sendKey(keyval: number, keyState: Clutter.KeyState) { 47 | this._virtualDevice.notify_keyval( 48 | Clutter.get_current_event_time() * 1000, 49 | keyval, 50 | keyState 51 | ); 52 | } 53 | } 54 | 55 | export type IVirtualKeyboard = VirtualKeyboard; 56 | 57 | let _keyboard: VirtualKeyboard | undefined; 58 | 59 | /** 60 | * 61 | */ 62 | export function getVirtualKeyboard() { 63 | _keyboard = _keyboard ?? new VirtualKeyboard(); 64 | return _keyboard; 65 | } 66 | 67 | /** 68 | * 69 | */ 70 | export function extensionCleanup() { 71 | timeoutIds.forEach(id => GLib.Source.remove(id)); 72 | timeoutIds.clear(); 73 | _keyboard = undefined; 74 | } 75 | -------------------------------------------------------------------------------- /extension/src/volumeControl.ts: -------------------------------------------------------------------------------- 1 | import Clutter from 'gi://Clutter'; 2 | import Shell from 'gi://Shell'; 3 | import {SwipeTracker} from 'resource:///org/gnome/shell/ui/swipeTracker.js'; 4 | import {createSwipeTracker} from './swipeTracker.js'; 5 | import {getVirtualKeyboard, IVirtualKeyboard} from './utils/keyboard.js'; 6 | import {TouchpadConstants} from '../constants.js'; 7 | 8 | enum VolumeGestureState { 9 | VOLUME_UP = 1, 10 | DEFAULT = 0, 11 | VOLUME_DOWN = -1, 12 | } 13 | 14 | export class VolumeControlGestureExtension implements ISubExtension { 15 | private _verticalSwipeTracker?: SwipeTracker; 16 | private _horizontalSwipeTracker?: SwipeTracker; 17 | private _verticalConnectHandlers?: number[]; 18 | private _horizontalConnectHandlers?: number[]; 19 | private _keyboard: IVirtualKeyboard; 20 | private _volumeGestureState: VolumeGestureState; 21 | private _progress = 0; 22 | 23 | constructor() { 24 | this._keyboard = getVirtualKeyboard(); 25 | this._progress = 0; 26 | this._volumeGestureState = VolumeGestureState.DEFAULT; 27 | } 28 | 29 | apply() { 30 | this._keyboard = getVirtualKeyboard(); 31 | this._progress = 0; 32 | this._volumeGestureState = VolumeGestureState.DEFAULT; 33 | } 34 | 35 | destroy(): void { 36 | this._verticalConnectHandlers?.forEach(handle => 37 | this._verticalSwipeTracker?.disconnect(handle) 38 | ); 39 | this._verticalConnectHandlers = undefined; 40 | this._verticalSwipeTracker?.destroy(); 41 | 42 | this._horizontalConnectHandlers?.forEach(handle => 43 | this._horizontalSwipeTracker?.disconnect(handle) 44 | ); 45 | this._horizontalConnectHandlers = undefined; 46 | this._horizontalSwipeTracker?.destroy(); 47 | 48 | this._progress = 0; 49 | } 50 | 51 | setVerticalSwipeTracker(nfingers: number[]) { 52 | this._verticalSwipeTracker = createSwipeTracker( 53 | global.stage, 54 | nfingers, 55 | Shell.ActionMode.NORMAL | Shell.ActionMode.OVERVIEW, 56 | Clutter.Orientation.VERTICAL, 57 | true, 58 | TouchpadConstants.VOLUME_CONTROL_MULTIPLIER, 59 | {allowTouch: false} 60 | ); 61 | 62 | this._verticalConnectHandlers = [ 63 | this._verticalSwipeTracker.connect( 64 | 'begin', 65 | this._gestureBegin.bind(this) 66 | ), 67 | this._verticalSwipeTracker.connect( 68 | 'update', 69 | this._gestureUpdate.bind(this) 70 | ), 71 | this._verticalSwipeTracker.connect( 72 | 'end', 73 | this._gestureEnd.bind(this) 74 | ), 75 | ]; 76 | } 77 | 78 | setHorizontalSwipeTracker(nfingers: number[]) { 79 | this._horizontalSwipeTracker = createSwipeTracker( 80 | global.stage, 81 | nfingers, 82 | Shell.ActionMode.NORMAL | Shell.ActionMode.OVERVIEW, 83 | Clutter.Orientation.HORIZONTAL, 84 | true, 85 | 1, 86 | {allowTouch: false} 87 | ); 88 | 89 | this._horizontalConnectHandlers = [ 90 | this._horizontalSwipeTracker.connect( 91 | 'begin', 92 | this._gestureBegin.bind(this) 93 | ), 94 | this._horizontalSwipeTracker.connect( 95 | 'update', 96 | this._gestureUpdate.bind(this) 97 | ), 98 | this._horizontalSwipeTracker.connect( 99 | 'end', 100 | this._gestureEnd.bind(this) 101 | ), 102 | ]; 103 | } 104 | 105 | _gestureBegin(_tracker: SwipeTracker): void { 106 | this._volumeGestureState = VolumeGestureState.DEFAULT; 107 | _tracker.confirmSwipe( 108 | global.screen_height, 109 | [ 110 | VolumeGestureState.VOLUME_DOWN, 111 | VolumeGestureState.DEFAULT, 112 | VolumeGestureState.VOLUME_UP, 113 | ], 114 | VolumeGestureState.DEFAULT, 115 | VolumeGestureState.DEFAULT 116 | ); 117 | } 118 | 119 | _gestureUpdate(_tracker: SwipeTracker, progress: number): void { 120 | this._progress = Math.clamp( 121 | progress, 122 | VolumeGestureState.VOLUME_DOWN, 123 | VolumeGestureState.VOLUME_UP 124 | ); 125 | 126 | switch (this._volumeGestureState) { 127 | case VolumeGestureState.DEFAULT: 128 | if (this._progress > VolumeGestureState.DEFAULT) { 129 | this._volumeGestureState = VolumeGestureState.VOLUME_UP; 130 | } 131 | 132 | if (this._progress < VolumeGestureState.DEFAULT) { 133 | this._volumeGestureState = VolumeGestureState.VOLUME_DOWN; 134 | } 135 | 136 | break; 137 | case VolumeGestureState.VOLUME_UP: 138 | this._keyboard.sendKeys([Clutter.KEY_AudioRaiseVolume]); 139 | this._volumeGestureState = VolumeGestureState.DEFAULT; 140 | break; 141 | case VolumeGestureState.VOLUME_DOWN: 142 | this._keyboard.sendKeys([Clutter.KEY_AudioLowerVolume]); 143 | this._volumeGestureState = VolumeGestureState.DEFAULT; 144 | } 145 | } 146 | 147 | _gestureEnd( 148 | _tracker: SwipeTracker, 149 | duration: number, 150 | progress: VolumeGestureState 151 | ): void { 152 | this._volumeGestureState = VolumeGestureState.DEFAULT; 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /extension/stylesheet.css: -------------------------------------------------------------------------------- 1 | .gie-alttab-quick-transition .switcher-popup, 2 | .gie-alttab-quick-transition .switcher-list, 3 | .gie-alttab-quick-transition .item-box { 4 | transition-duration: 0ms; 5 | } 6 | 7 | .gie-circle { 8 | width: 30px; 9 | height: 30px; 10 | border-radius: 30px; 11 | } 12 | 13 | .gie-inner-circle { 14 | border: solid 2px #396bd7; 15 | color: #396bd7; 16 | background-color: #ffffff; 17 | } 18 | 19 | .gie-inner-circle-dark { 20 | border: solid 2px #396bd7; 21 | color: #396bd7; 22 | background-color: #242323; 23 | } 24 | 25 | .gie-arrow-icon { 26 | color: #396bd7; 27 | margin: 4px 4px; /* (30+2 - 25)/2 */ 28 | icon-size: 24px; 29 | } 30 | 31 | .gie-outer-circle { 32 | background-color: rgba(68, 120, 175, 0.5); 33 | border: solid 2px; 34 | } 35 | 36 | .gie-close-window-preview { 37 | background-color: rgba(255, 128, 128, 0.5); 38 | border: 1px solid rgba(255, 128, 128); 39 | border-radius: 12px; 40 | } 41 | 42 | .gie-tile-window-preview { 43 | border-radius: 12px; 44 | } 45 | -------------------------------------------------------------------------------- /extension/types/global.d.ts: -------------------------------------------------------------------------------- 1 | declare interface IExtension { 2 | enable(): void; 3 | disable(): void; 4 | } 5 | 6 | declare interface ISubExtension { 7 | apply?(): void; 8 | destroy(): void; 9 | } 10 | 11 | declare type KeysThatStartsWith< 12 | K extends string, 13 | U extends string, 14 | > = K extends `${U}${infer _R}` ? K : never; 15 | 16 | declare type KeysOfType = { 17 | [P in keyof T]: T[P] extends U ? P : never; 18 | }[keyof T]; 19 | -------------------------------------------------------------------------------- /extension/types/gnome-shell/misc/utils.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'resource:///org/gnome/shell/misc/util.js' { 2 | function spawn(argv: string[]): void; 3 | function lerp(start: number, end: number, progress: number): number; 4 | } 5 | -------------------------------------------------------------------------------- /extension/types/gnome-shell/ui/altTab.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'resource:///org/gnome/shell/ui/altTab.js' { 2 | import St from 'gi://St'; 3 | import Meta from 'gi://Meta'; 4 | import {SwitcherPopup} from 'resource:///org/gnome/shell/ui/switcherPopup.js'; 5 | 6 | class WindowSwitcherPopup extends SwitcherPopup { 7 | _items: St.Widget & 8 | { 9 | window: Meta.Window; 10 | }[]; 11 | _switcherList: St.Widget & { 12 | _scrollView: St.ScrollView; 13 | }; 14 | _noModsTimeoutId: number; 15 | _initialDelayTimeoutId: number; 16 | _selectedIndex: number; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /extension/types/gnome-shell/ui/main.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'resource:///org/gnome/shell/ui/main.js' { 2 | import Meta from 'gi://Meta'; 3 | import Clutter from 'gi://Clutter'; 4 | import St from 'gi://St'; 5 | import Shell from 'gi://Shell'; 6 | 7 | import {ControlsManager} from 'resource:///org/gnome/shell/ui/overviewControls.js'; 8 | import {SwipeTracker} from 'resource:///org/gnome/shell/ui/swipeTracker.js'; 9 | import {WindowManager} from 'resource:///org/gnome/shell/ui/windowManager.js'; 10 | import {WorkspaceAnimationController} from 'resource:///org/gnome/shell/ui/workspaceAnimation.js'; 11 | 12 | const actionMode: Shell.ActionMode; 13 | export function activateWindow( 14 | window: Meta.Window, 15 | time?: number, 16 | workspaceNum?: number 17 | ): void; 18 | 19 | const panel: { 20 | addToStatusArea( 21 | role: string, 22 | indicator: Clutter.Actor, 23 | position?: number, 24 | box?: string 25 | ): void; 26 | } & Clutter.Actor; 27 | 28 | const overview: { 29 | dash: { 30 | showAppsButton: St.Button; 31 | }; 32 | searchEntry: St.Entry; 33 | shouldToggleByCornerOrButton(): boolean; 34 | visible: boolean; 35 | show(): void; 36 | hide(): void; 37 | showApps(): void; 38 | connect( 39 | signal: 'showing' | 'hiding' | 'hidden' | 'shown', 40 | callback: () => void 41 | ): number; 42 | disconnect(id: number): void; 43 | _overview: { 44 | _controls: ControlsManager; 45 | } & St.Widget; 46 | _gestureBegin(tracker: { 47 | confirmSwipe: typeof SwipeTracker.prototype.confirmSwipe; 48 | }): void; 49 | _gestureUpdate(tracker: SwipeTracker, progress: number): void; 50 | _gestureEnd( 51 | tracker: SwipeTracker, 52 | duration: number, 53 | endProgress: number 54 | ): void; 55 | 56 | _swipeTracker: SwipeTracker; 57 | }; 58 | 59 | const wm: WindowManager & { 60 | skipNextEffect(actor: Meta.WindowActor): void; 61 | _workspaceAnimation: WorkspaceAnimationController; 62 | }; 63 | 64 | const osdWindowManager: { 65 | hideAll(): void; 66 | }; 67 | } 68 | -------------------------------------------------------------------------------- /extension/types/gnome-shell/ui/overviewControls.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'resource:///org/gnome/shell/ui/overviewControls.js' { 2 | import St from 'gi://St'; 3 | import Clutter from 'gi://Clutter'; 4 | 5 | import {SwipeTracker} from 'resource:///org/gnome/shell/ui/swipeTracker.js'; 6 | 7 | enum ControlsState { 8 | HIDDEN, 9 | WINDOW_PICKER, 10 | APP_GRID, 11 | } 12 | 13 | class OverviewAdjustment extends St.Adjustment { 14 | getStateTransitionParams(): { 15 | initialState: ControlsState; 16 | finalState: ControlsState; 17 | currentState: number; 18 | progress: number; 19 | }; 20 | } 21 | 22 | class ControlsManager extends St.Widget { 23 | _stateAdjustment: OverviewAdjustment; 24 | layout_Manager: Clutter.BoxLayout & { 25 | _searchEntry: St.Bin; 26 | }; 27 | 28 | _toggleAppsPage(): void; 29 | 30 | _workspacesDisplay: { 31 | _swipeTracker: SwipeTracker; 32 | }; 33 | _appDisplay: { 34 | _swipeTracker: SwipeTracker; 35 | }; 36 | _searchController: { 37 | searchActive: boolean; 38 | }; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /extension/types/gnome-shell/ui/swipeTracker.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'resource:///org/gnome/shell/ui/swipeTracker.js' { 2 | import Clutter from 'gi://Clutter'; 3 | import GObject from 'gi://GObject'; 4 | import Shell from 'gi://Shell'; 5 | 6 | class TouchpadGesture extends GObject.Object { 7 | destroy(): void; 8 | 9 | _handleEvent( 10 | actor: Clutter.Actor | undefined, 11 | event: CustomEventType 12 | ): boolean; 13 | } 14 | 15 | class SwipeTracker extends GObject.Object { 16 | constructor( 17 | actor: Clutter.Actor, 18 | orientation: Clutter.Orientation, 19 | allowedModes: Shell.ActionMode, 20 | params?: _SwipeTrackerOptionalParams 21 | ); 22 | 23 | orientation: Clutter.Orientation; 24 | enabled: boolean; 25 | allowLongSwipes: boolean; 26 | 27 | confirmSwipe( 28 | distance: number, 29 | snapPoints: number[], 30 | currentProgress: number, 31 | cancelProgress: number 32 | ): void; 33 | 34 | destroy(): void; 35 | 36 | _touchGesture?: Clutter.GestureAction; 37 | _touchpadGesture?: TouchpadGesture; 38 | _oldTouchpadGesture?: TouchpadGesture; // custom 39 | _allowedModes: Shell.ActionMode; 40 | _progress: number; 41 | 42 | _beginGesture(): void; 43 | 44 | _updateGesture(): void; 45 | 46 | _endTouchpadGesture(): void; 47 | 48 | _history: { 49 | reset(): void; 50 | }; 51 | } 52 | 53 | export type _SwipeTrackerOptionalParams = { 54 | allowTouch?: boolean; 55 | allowDrag?: boolean; 56 | allowScroll?: boolean; 57 | }; 58 | 59 | // types 60 | export type CustomEventType = Pick< 61 | Clutter.Event, 62 | | 'type' 63 | | 'get_gesture_phase' 64 | | 'get_touchpad_gesture_finger_count' 65 | | 'get_time' 66 | | 'get_coords' 67 | | 'get_gesture_motion_delta_unaccelerated' 68 | | 'get_gesture_pinch_scale' 69 | | 'get_gesture_pinch_angle_delta' 70 | >; 71 | } 72 | -------------------------------------------------------------------------------- /extension/types/gnome-shell/ui/workspaceAnimation.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'resource:///org/gnome/shell/ui/workspaceAnimation.js' { 2 | import Clutter from 'gi://Clutter'; 3 | import Meta from 'gi://Meta'; 4 | 5 | import {SwipeTracker} from 'resource:///org/gnome/shell/ui/swipeTracker.js'; 6 | 7 | export class WorkspaceAnimationController { 8 | _swipeTracker: SwipeTracker; 9 | 10 | _switchWorkspaceBegin( 11 | tracker: { 12 | orientation: Clutter.Orientation; 13 | confirmSwipe: typeof SwipeTracker.prototype.confirmSwipe; 14 | }, 15 | monitor: number 16 | ): void; 17 | 18 | _switchWorkspaceUpdate(tracker: SwipeTracker, progress: number): void; 19 | 20 | _switchWorkspaceEnd( 21 | tracker: SwipeTracker, 22 | duration: number, 23 | progress: number 24 | ): void; 25 | 26 | _movingWindow: Meta.Window; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /extension/ui/customizations.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Cyclic 9 | GNOME 10 | Overview only 11 | 12 | 13 | 14 | 15 | Misc 16 | emblem-system-symbolic 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | Touchpad swipe speed 27 | Make the action triggered by a swipe gesture happen faster or slower 28 | 29 | 30 | 31 | 100 32 | true 33 | no 34 | 35 | 36 | 37 | -3.3219280948873626 38 | 3.3219280948873626 39 | 0.01 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | center 53 | 5 54 | False 55 | False 56 | 1.00 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | Touchpad pinch speed 67 | Make the action triggered by a pinch gesture happen faster or slower 68 | 69 | 70 | 71 | 100 72 | true 73 | no 74 | 75 | 76 | 77 | -3.3219280948873626 78 | 3.3219280948873626 79 | 0.01 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | center 93 | 5 94 | False 95 | False 96 | 1.00 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | Volume control speed 107 | Adjust the rate of change for volume control by swipe gesture 108 | 109 | 110 | 111 | 100 112 | true 113 | no 114 | 115 | 116 | -3.3219280948873626 117 | 3.3219280948873626 118 | 0.01 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | center 132 | 5 133 | False 134 | False 135 | 1.00 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | Follow natural swipe for workspace switching 146 | 147 | 148 | center 149 | True 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | Revert direction of overview navigation gesture 159 | Swipe down instead of swipe up for overview 160 | 161 | 162 | center 163 | False 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | Enable vertical swipe for app gestures 173 | [Experimental] Need to disbale 3/4-fingers vertical swipe to activate 174 | 175 | 176 | center 177 | False 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | Enable Minimize window for Window Manipulation 187 | Minimize window using vertical swipe. This will disable tiling gesture. Swipe down to activate 188 | 189 | 190 | center 191 | True 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | Overview navigation states 201 | Customize vertical swipe for overview and app-grid navigation 202 | overview_navigation_states_model 203 | 204 | 205 | 206 | 207 | 208 | 209 | Window switcher popup delay (ms) 210 | 211 | 212 | center 213 | 214 | 215 | 0 216 | 5000 217 | 5 218 | 219 | 220 | 150 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | Duration between hold and swipe (ms) 230 | 231 | 232 | center 233 | 234 | 235 | 0 236 | 5000 237 | 5 238 | 239 | 240 | 100 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | -------------------------------------------------------------------------------- /extension/ui/gestures.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | None 9 | Show Desktop 10 | Close Window 11 | Close Tab (Ctrl + W) 12 | 13 | 14 | 15 | 16 | 17 | None 18 | Overview Navigation 19 | Workspace Switching 20 | Window Switching 21 | Volume Control 22 | Window Manipulation 23 | 24 | 25 | 26 | 27 | 28 | None 29 | Overview Navigation 30 | Workspace Switching 31 | Window Switching 32 | Volume Control 33 | 34 | 35 | 36 | 37 | System Gestures 38 | gesture-swipe-right-symbolic 39 | 40 | 41 | 42 | 43 | 3-Fingers Swipe Gestures 44 | 45 | 46 | 47 | 48 | 3-fingers vertical swipe gesture 49 | vertical_swipe_gestures_model 50 | 51 | 52 | 53 | 54 | 55 | 56 | 3-fingers horizontal swipe gesture 57 | horizontal_swipe_gestures_model 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 4-Fingers Swipe Gestures 68 | 69 | 70 | 71 | 72 | 4-fingers vertical swipe gesture 73 | vertical_swipe_gestures_model 74 | 75 | 76 | 77 | 78 | 79 | 80 | 4-fingers horizontal swipe gesture 81 | horizontal_swipe_gestures_model 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | Pinch Gestures 92 | 93 | 94 | 95 | 96 | 3-fingers pinch gesture 97 | pinch_gestures_model 98 | 99 | 100 | 101 | 102 | 103 | 104 | 4-fingers pinch gesture 105 | pinch_gestures_model 106 | 107 | 108 | 109 | 110 | 111 | -------------------------------------------------------------------------------- /extension/ui/style-dark.css: -------------------------------------------------------------------------------- 1 | .custom-smaller-card { 2 | min-height: 35px; 3 | } 4 | 5 | .custom-information-label-row { 6 | background-color: #44403b; 7 | } 8 | -------------------------------------------------------------------------------- /extension/ui/style.css: -------------------------------------------------------------------------------- 1 | .custom-smaller-card { 2 | min-height: 35px; 3 | } 4 | 5 | .custom-information-label-row { 6 | background-color: #f1e6d9; 7 | } 8 | -------------------------------------------------------------------------------- /metadata.json: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "name": "Touchpad Gesture Customization", 4 | "description": "An extension which enable touchpad gestures customization in GNOME using Wayland. For full list of supported features please visit https://github.com/HieuTNg/touchpad-gesture-customization", 5 | "uuid": "touchpad-gesture-customization@coooolapps.com", 6 | "url": "https://github.com/HieuTNg/touchpad-gesture-customization", 7 | "settings-schema": "org.gnome.shell.extensions.touchpad-gesture-customization", 8 | "shell-version": [ 9 | "45", 10 | "46", 11 | "47", 12 | "48" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "touchpad-gesture-customization", 3 | "version": "1.0.0", 4 | "description": "Enable touchpad gestures customization in GNOME using Wayland", 5 | "type": "module", 6 | "private": true, 7 | "main": "extension.js", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/HieuTNg/touchpad-gesture-customization" 11 | }, 12 | "author": "Hieu Trung Nguyen (https://coooolapps.com/)", 13 | "license": "LGPL-3.0-or-later", 14 | "bugs": { 15 | "url": "https://github.com/HieuTNg/touchpad-gesture-customization/issues" 16 | }, 17 | "homepage": "https://github.com/HieuTNg/touchpad-gesture-customization", 18 | "sideEffects": false, 19 | "scripts": { 20 | "clean-dev": "rm -rf build node_modules", 21 | "lint:package": "eslint build --fix", 22 | "format:build": "npx prettier build --write", 23 | "clean": "rm -rf build && mkdir build", 24 | "lint:extension": "eslint extension --fix", 25 | "format:extension": "npx prettier extension --write", 26 | "transpile": "npm run lint:extension && npm run format:extension && tsc", 27 | "build": "npm run clean && npm run transpile && npm run lint:package && npm run format:build", 28 | "pack": "npm run build && make pack", 29 | "update": "npm run pack && make update" 30 | }, 31 | "devDependencies": { 32 | "@eslint/js": "^9.17.0", 33 | "@stylistic/eslint-plugin": "^3.1.0", 34 | "eslint": "^9.17.0", 35 | "eslint-plugin-jsdoc": "^50.6.1", 36 | "prettier": "3.5.0", 37 | "typescript": "^5.7.2", 38 | "typescript-eslint": "^8.19.0" 39 | }, 40 | "dependencies": { 41 | "@girs/gjs": "^4.0.0-beta.19", 42 | "@girs/gnome-shell": "^47.0.0" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "NodeNext", 4 | "moduleResolution": "NodeNext", 5 | "outDir": "./build", 6 | "sourceMap": false, 7 | "strict": true, 8 | "target": "ES2021", 9 | "lib": [ 10 | "ES2021" 11 | ], 12 | }, 13 | "include": [ 14 | "ambient.d.ts", 15 | "extension/**/*.ts", 16 | "extension/**/*.js", 17 | ], 18 | } --------------------------------------------------------------------------------