├── .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 |
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 |
408 |
--------------------------------------------------------------------------------
/extension/assets/arrow1-right-symbolic.svg:
--------------------------------------------------------------------------------
1 |
2 |
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 |
13 |
14 |
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 | }
--------------------------------------------------------------------------------