├── .eslintrc
├── .gitignore
├── .vscode
└── settings.json
├── README.md
├── extension.js
├── lint
├── eslintrc-gjs.yml
└── eslintrc-shell.yml
├── logger.js
├── metadata.json
└── reactive.js
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "root": true,
3 | "extends": [
4 | "./lint/eslintrc-gjs.yml",
5 | "./lint/eslintrc-shell.yml"
6 | ],
7 | "plugins": [
8 | ],
9 | "rules": {
10 | "no-tabs": "warn",
11 | // "indent": ["warn", 4, { "SwitchCase": 1 }],
12 | "eol-last": "off",
13 | "no-undef": "warn",
14 | "semi": ["error", "always"],
15 | "no-console": "off",
16 | "no-useless-escape": "off",
17 | "no-empty": "off",
18 | "camelcase": ["warn", { "properties": "never" }],
19 | "prefer-const": ["error"],
20 | "block-spacing": ["warn", "always"],
21 | "comma-spacing": ["warn", { "before": false, "after": true }],
22 | "comma-style": ["warn", "last"],
23 | // "comma-dangle": ["warn", "never"],
24 | "curly": ["warn", "all"],
25 | "brace-style": ["warn", "1tbs", { "allowSingleLine": true }],
26 | "no-mixed-operators": ["warn", { "allowSamePrecedence": true }],
27 | "operator-linebreak": ["warn", "after"],
28 | "object-curly-newline": ["warn", { "consistent": true }],
29 | "object-curly-spacing": ["warn", "always"],
30 | "array-bracket-newline": ["warn", "consistent"],
31 | "array-element-newline": ["warn", "consistent"],
32 | "semi-spacing": "warn",
33 | "space-before-blocks": ["warn", "always"],
34 | "space-infix-ops": ["warn", { "int32Hint": false }],
35 | "space-unary-ops": "warn",
36 | "no-mixed-spaces-and-tabs": "warn",
37 | "no-whitespace-before-property": "warn",
38 | "switch-colon-spacing": "error",
39 | "key-spacing": ["warn"],
40 | "keyword-spacing": ["warn", { "before": true }],
41 | "space-before-function-paren": [
42 | "warn",
43 | { "anonymous": "always", "named": "never", "asyncArrow": "always" }
44 | ],
45 | "no-unused-vars": ["error", { "vars": "all", "args": "none" }],
46 | "no-unsafe-optional-chaining": "error",
47 | "quotes": ["warn", "single", { "avoidEscape": true }],
48 | "no-param-reassign": "error"
49 | },
50 |
51 | "globals": {
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 |
2 | # Created by https://www.gitignore.io/api/linux
3 | # Edit at https://www.gitignore.io/?templates=linux
4 |
5 | ### Linux ###
6 | *~
7 |
8 | # temporary files which can be created if a process still has a handle open of a deleted file
9 | .fuse_hidden*
10 |
11 | # KDE directory preferences
12 | .directory
13 |
14 | # Linux trash folder which might appear on any partition or disk
15 | .Trash-*
16 |
17 | # .nfs files are created when an open file is removed but is still being accessed
18 | .nfs*
19 |
20 | # End of https://www.gitignore.io/api/linux
21 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "eslint.format.enable": true,
3 | "eslint.debug": false,
4 | "eslint.workingDirectories": [{ "mode": "location" }],
5 |
6 | "javascript.format.enable": false,
7 | "json.format.enable": false,
8 |
9 | "editor.formatOnSaveMode": "file",
10 |
11 | "[javascript]": {
12 | "editor.defaultFormatter": "dbaeumer.vscode-eslint",
13 | "editor.detectIndentation": false,
14 | "editor.tabSize": 4,
15 | "editor.formatOnType": true,
16 | "editor.formatOnSaveMode": "modifications",
17 | "editor.codeActionsOnSave": {
18 | "source.fixAll.eslint": "explicit"
19 | }
20 | },
21 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # RX Input Layout Switcher GNOME Shell Extension
2 |
3 | This extension makes it possible to switch keyboard layout with the ALT+SHIFT modifier keys without interrupting other shortcuts that use these modifiers as part of their key combination.
4 |
5 | ## Installation
6 |
7 | Directly from GitHub:
8 |
9 | ```shell
10 | $ git clone https://github.com/arikw/rx-input-layout-switcher ~/.local/share/gnome-shell/extensions/rx-input-layout-switcher@wzmn.net
11 |
12 | $ gnome-extensions enable rx-input-layout-switcher@wzmn.net
13 | ```
14 |
15 | ***Notice:*** In Wayland, if the above command reports that extension does not exist, logout and re-login
16 |
17 | ## Rational
18 | Well, it's hard to control remote Windows machines' input language on TeamViewer for Linux, because the local input method is what determines the language on the remote machine. It's not possible to switch the language if the modifier keys are set to be sent to the remote machine, so to switch the language on the remote, you need to turn off the "Send key combinations", switch the language locally and turn it back on. Really annoying.
19 | In addition, if you are new to Linux DE and used to the good old `ALT+SHIFT` combo on Windows and want to do the same on GNOME, you'll end up breaking any shortcut that uses these keys.
20 |
21 | ### ✅ Advantages
22 | * Keeping the default language switching key combination from Windows
23 | * Switching input method in TeamViewer with "Send key combinations" option is enabled
24 | * Not interfering with other system or application keyboard accelerators
25 |
26 | ### ❌ Caveats
27 |
28 | * A layout switch might occur when using shortcuts involving the ALT+SHIFT keys. The extension attempts to mitigate this issue by switching language if ALT+SHIFT was pressed briefly. The idea is that shortcuts that involve more non-modifier keys will take longer
29 |
30 | ### 💡 Future Improvements \ Ideas
31 | * Use `xinput` for `X11` session to make sure that only the modifier keys were pressed to eliminate false layout switching
32 | * Make the layout switching timing sensitivity configurable to balance between the false-positive switches and ease of use
33 | * enable\disable layout switching for specific programs
34 |
35 | # Misc
36 |
37 | ## Development
38 | * Developed on Fedora 37
39 | * GNOME Shell Extensions that I got a great deal of help from reading their source codes:
40 | * ["Keyboard Modifiers Status"](https://github.com/sneetsher/Keyboard-Modifiers-Status) by [sneetsher](https://extensions.gnome.org/accounts/profile/sneetsher)
41 | * ["Quick Lang Switch"](https://github.com/ankostis/gnome-shell-quick-lang-switch) by [ankostis](https://extensions.gnome.org/accounts/profile/ankostis)
42 | * Websites worth mentioning:
43 | * [grep.app](https://grep.app/) to quickly search across many git repos
44 |
45 |
46 | ## Debugging
47 | Run `journalctl -f -o cat | grep rx-input-layout-switcher@wzmn.net` to see extension logs.
48 | Set `dbg` variable to `true` in `logger.js` to see debug-level logs.
49 | If the extension doesn't load, make sure to run the broader logging by running `journalctl -f -o cat`
--------------------------------------------------------------------------------
/extension.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2023 Arik W (https://github.com/arikw)
3 | *
4 | * This program is free software: you can redistribute it and/or modify
5 | * it under the terms of the GNU General Public License as published by
6 | * the Free Software Foundation, either version 2 of the License, or
7 | * (at your option) any later version.
8 | *
9 | * This program is distributed in the hope that it will be useful,
10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | * GNU General Public License for more details.
13 | *
14 | * You should have received a copy of the GNU General Public License
15 | * along with this program. If not, see .
16 | *
17 | * SPDX-License-Identifier: GPL-2.0-or-later
18 | */
19 |
20 | import Clutter from 'gi://Clutter';
21 | import { getInputSourceManager } from 'resource:///org/gnome/shell/ui/status/keyboard.js';
22 | import { d, printState } from './logger.js';
23 | import { watch, unwatch, observable } from './reactive.js';
24 | import Glib from 'gi://GLib';
25 |
26 | const
27 | // consts
28 | ALT_AND_SHIFT_MASK = Clutter.ModifierType.MOD1_MASK | Clutter.ModifierType.SHIFT_MASK,
29 | { timeout_add, source_remove, PRIORITY_DEFAULT } = Glib;
30 |
31 | export default class RxInputLayoutSwitcher {
32 |
33 | enable() {
34 | this.inputSourceManager = getInputSourceManager();
35 | this.state = {
36 | modifiers: observable({
37 | bits: null,
38 | sequence: [],
39 | isBroken: false,
40 | }),
41 | };
42 |
43 | watch(this.state.modifiers, 'bits', this._onModifierBitsChange.bind(this));
44 | this.state.modifiers.bits = this._getCurrentModifiers();
45 | this.mainLoopTimerId = timeout_add(PRIORITY_DEFAULT, 50, this._tick.bind(this));
46 | this.acceleratorListenerId = global.display.connect('accelerator-activated', () => {
47 | d('accelerator activation detected');
48 | this.state.modifiers.isBroken = true;
49 | });
50 | }
51 |
52 | disable() {
53 | source_remove(this.mainLoopTimerId);
54 | global.display.disconnect(this.acceleratorListenerId);
55 | unwatch(this.state.modifiers, 'bits', this._onModifierBitsChange);
56 | this.inputSourceManager = null;
57 | this.state = null;
58 | }
59 |
60 | _addToSequence(descriptor) {
61 | const modifiersSequence = this.state.modifiers.sequence;
62 | const previousState = modifiersSequence[modifiersSequence.length - 1] ?? {};
63 | modifiersSequence.push(descriptor);
64 |
65 | // break sequence if non ALT\SHIFT bits are raised
66 | if (descriptor.bits & ~ALT_AND_SHIFT_MASK) {
67 | this.state.modifiers.isBroken = true;
68 | }
69 |
70 | if (modifiersSequence.length > 5 /* max relevant sequence length */) {
71 | modifiersSequence.shift(); // remove oldest event
72 | }
73 |
74 | let shouldSwitch = false;
75 | if (
76 | !this.state.modifiers.isBroken &&
77 | modifiersSequence.length >= 1 &&
78 | modifiersSequence[0].bits === 0 && // sequence started without pressed modifiers
79 | (previousState.bits === ALT_AND_SHIFT_MASK)
80 | ) {
81 | // shift+shift pressed briefly?
82 | if ((descriptor.date - previousState.date) < 300) {
83 | shouldSwitch = true;
84 | } else {
85 | this.state.modifiers.isBroken = true;
86 | }
87 | }
88 |
89 | if (shouldSwitch) {
90 | this._switchInputMethod();
91 | }
92 |
93 | if (shouldSwitch || (this.state.modifiers.bits === 0)) {
94 | d('--------------');
95 | this._resetSequence(0);
96 | }
97 | d(JSON.stringify({ time: descriptor.date - (modifiersSequence[1]?.date ?? 0), isBroken: this.state.modifiers.isBroken, previousState: previousState.bits, althisft: previousState.bits === ALT_AND_SHIFT_MASK }));
98 | }
99 |
100 | _switchInputMethod() {
101 | const numOfInputSources = Object.keys(this.inputSourceManager.inputSources).length;
102 | const currentInput = this.inputSourceManager.currentSource;
103 | const nextInput = this.inputSourceManager.inputSources[(currentInput.index + 1) % numOfInputSources];
104 | nextInput.activate();
105 |
106 | d(`switched input method from ${currentInput.shortName}-${currentInput.id} to ${nextInput.shortName}-${nextInput.id}`, 'info');
107 | }
108 |
109 | _tick() {
110 | const previousState = this.state.modifiers.bits;
111 | const mods = this._getCurrentModifiers();
112 |
113 | if (previousState !== mods) {
114 | this.state.modifiers.bits = mods;
115 | printState(this.state.modifiers);
116 | }
117 |
118 | return true;
119 | }
120 |
121 | _getCurrentModifiers() {
122 | const [, , mods] = global.get_pointer();
123 | return mods &
124 | // Remove caps-lock and num-lock states from state mask
125 | ~(Clutter.ModifierType.LOCK_MASK | Clutter.ModifierType.MOD2_MASK);
126 | }
127 |
128 | _resetSequence(mods) {
129 | this.state.modifiers.sequence = [
130 | {
131 | bits: mods,
132 | date: Date.now(),
133 | },
134 | ];
135 | this.state.modifiers.isBroken = mods !== 0;
136 | }
137 |
138 | _onModifierBitsChange(bits) {
139 | this._addToSequence({ bits, date: Date.now() });
140 | }
141 |
142 | }
--------------------------------------------------------------------------------
/lint/eslintrc-gjs.yml:
--------------------------------------------------------------------------------
1 | ---
2 | # SPDX-License-Identifier: MIT OR LGPL-2.0-or-later
3 | # SPDX-FileCopyrightText: 2018 Claudio André
4 | env:
5 | es2021: true
6 | extends: 'eslint:recommended'
7 | #plugins:
8 | # - jsdoc
9 | rules:
10 | array-bracket-newline:
11 | - error
12 | - consistent
13 | array-bracket-spacing:
14 | - error
15 | - never
16 | array-callback-return: error
17 | arrow-parens:
18 | - error
19 | - as-needed
20 | arrow-spacing: error
21 | block-scoped-var: error
22 | block-spacing: error
23 | brace-style: error
24 | # Waiting for this to have matured a bit in eslint
25 | # camelcase:
26 | # - error
27 | # - properties: never
28 | # allow: [^vfunc_, ^on_, _instance_init]
29 | comma-dangle:
30 | - error
31 | - arrays: always-multiline
32 | objects: always-multiline
33 | functions: never
34 | comma-spacing:
35 | - error
36 | - before: false
37 | after: true
38 | comma-style:
39 | - error
40 | - last
41 | computed-property-spacing: error
42 | curly:
43 | - error
44 | - multi-or-nest
45 | - consistent
46 | dot-location:
47 | - error
48 | - property
49 | eol-last: error
50 | eqeqeq: error
51 | func-call-spacing: error
52 | func-name-matching: error
53 | func-style:
54 | - error
55 | - declaration
56 | - allowArrowFunctions: true
57 | indent:
58 | - error
59 | - 4
60 | - ignoredNodes:
61 | # Allow not indenting the body of GObject.registerClass, since in the
62 | # future it's intended to be a decorator
63 | - 'CallExpression[callee.object.name=GObject][callee.property.name=registerClass] > ClassExpression:first-child'
64 | # Allow dedenting chained member expressions
65 | MemberExpression: 'off'
66 | # jsdoc/check-alignment: error
67 | # jsdoc/check-param-names: error
68 | # jsdoc/check-tag-names: error
69 | # jsdoc/check-types: error
70 | # jsdoc/implements-on-classes: error
71 | # jsdoc/newline-after-description: error
72 | # jsdoc/require-jsdoc: error
73 | # jsdoc/require-param: error
74 | # jsdoc/require-param-description: error
75 | # jsdoc/require-param-name: error
76 | # jsdoc/require-param-type: error
77 | key-spacing:
78 | - error
79 | - beforeColon: false
80 | afterColon: true
81 | keyword-spacing:
82 | - error
83 | - before: true
84 | after: true
85 | linebreak-style:
86 | - error
87 | - unix
88 | lines-between-class-members:
89 | - error
90 | - always
91 | - exceptAfterSingleLine: true
92 | max-nested-callbacks: error
93 | max-statements-per-line: error
94 | new-parens: error
95 | no-array-constructor: error
96 | no-await-in-loop: error
97 | no-caller: error
98 | no-constant-condition:
99 | - error
100 | - checkLoops: false
101 | no-div-regex: error
102 | no-empty:
103 | - error
104 | - allowEmptyCatch: true
105 | no-extra-bind: error
106 | no-extra-parens:
107 | - error
108 | - all
109 | - conditionalAssign: false
110 | nestedBinaryExpressions: false
111 | returnAssign: false
112 | no-implicit-coercion:
113 | - error
114 | - allow:
115 | - '!!'
116 | no-invalid-this: error
117 | no-iterator: error
118 | no-label-var: error
119 | no-lonely-if: error
120 | no-loop-func: error
121 | no-nested-ternary: error
122 | no-new-object: error
123 | no-new-wrappers: error
124 | no-octal-escape: error
125 | no-proto: error
126 | no-prototype-builtins: 'off'
127 | no-restricted-globals: [error, window]
128 | no-restricted-properties:
129 | - error
130 | - object: imports
131 | property: format
132 | message: Use template strings
133 | - object: pkg
134 | property: initFormat
135 | message: Use template strings
136 | - object: Lang
137 | property: copyProperties
138 | message: Use Object.assign()
139 | - object: Lang
140 | property: bind
141 | message: Use arrow notation or Function.prototype.bind()
142 | - object: Lang
143 | property: Class
144 | message: Use ES6 classes
145 | no-restricted-syntax:
146 | - error
147 | - selector: >-
148 | MethodDefinition[key.name="_init"] >
149 | FunctionExpression[params.length=1] >
150 | BlockStatement[body.length=1]
151 | CallExpression[arguments.length=1][callee.object.type="Super"][callee.property.name="_init"] >
152 | Identifier:first-child
153 | message: _init() that only calls super._init() is unnecessary
154 | - selector: >-
155 | MethodDefinition[key.name="_init"] >
156 | FunctionExpression[params.length=0] >
157 | BlockStatement[body.length=1]
158 | CallExpression[arguments.length=0][callee.object.type="Super"][callee.property.name="_init"]
159 | message: _init() that only calls super._init() is unnecessary
160 | - selector: BinaryExpression[operator="instanceof"][right.name="Array"]
161 | message: Use Array.isArray()
162 | no-return-assign: error
163 | no-return-await: error
164 | no-self-compare: error
165 | no-shadow: error
166 | no-shadow-restricted-names: error
167 | no-spaced-func: error
168 | no-tabs: error
169 | no-template-curly-in-string: error
170 | no-throw-literal: error
171 | no-trailing-spaces: error
172 | no-undef-init: error
173 | no-unneeded-ternary: error
174 | no-unused-expressions: error
175 | no-unused-vars:
176 | - error
177 | # Vars use a suffix _ instead of a prefix because of file-scope private vars
178 | - varsIgnorePattern: (^unused|_$)
179 | argsIgnorePattern: ^(unused|_)
180 | no-useless-call: error
181 | no-useless-computed-key: error
182 | no-useless-concat: error
183 | no-useless-constructor: error
184 | no-useless-rename: error
185 | no-useless-return: error
186 | no-whitespace-before-property: error
187 | no-with: error
188 | nonblock-statement-body-position:
189 | - error
190 | - below
191 | object-curly-newline:
192 | - error
193 | - consistent: true
194 | multiline: true
195 | object-curly-spacing: error
196 | object-shorthand: error
197 | operator-assignment: error
198 | operator-linebreak: error
199 | padded-blocks:
200 | - error
201 | - never
202 | # These may be a bit controversial, we can try them out and enable them later
203 | # prefer-const: error
204 | # prefer-destructuring: error
205 | prefer-numeric-literals: error
206 | prefer-promise-reject-errors: error
207 | prefer-rest-params: error
208 | prefer-spread: error
209 | prefer-template: error
210 | quotes:
211 | - error
212 | - single
213 | - avoidEscape: true
214 | require-await: error
215 | rest-spread-spacing: error
216 | semi:
217 | - error
218 | - always
219 | semi-spacing:
220 | - error
221 | - before: false
222 | after: true
223 | semi-style: error
224 | space-before-blocks: error
225 | space-before-function-paren:
226 | - error
227 | - named: never
228 | # for `function ()` and `async () =>`, preserve space around keywords
229 | anonymous: always
230 | asyncArrow: always
231 | space-in-parens: error
232 | space-infix-ops:
233 | - error
234 | - int32Hint: false
235 | space-unary-ops: error
236 | spaced-comment: error
237 | switch-colon-spacing: error
238 | symbol-description: error
239 | template-curly-spacing: error
240 | template-tag-spacing: error
241 | unicode-bom: error
242 | wrap-iife:
243 | - error
244 | - inside
245 | yield-star-spacing: error
246 | yoda: error
247 | settings:
248 | jsdoc:
249 | mode: typescript
250 | globals:
251 | ARGV: readonly
252 | Debugger: readonly
253 | GIRepositoryGType: readonly
254 | globalThis: readonly
255 | imports: readonly
256 | Intl: readonly
257 | log: readonly
258 | logError: readonly
259 | print: readonly
260 | printerr: readonly
261 | window: readonly
262 | TextEncoder: readonly
263 | TextDecoder: readonly
264 | console: readonly
265 | setTimeout: readonly
266 | setInterval: readonly
267 | clearTimeout: readonly
268 | clearInterval: readonly
269 | parserOptions:
270 | ecmaVersion: 2022
271 |
--------------------------------------------------------------------------------
/lint/eslintrc-shell.yml:
--------------------------------------------------------------------------------
1 | rules:
2 | camelcase:
3 | - error
4 | - properties: never
5 | allow: [^vfunc_, ^on_]
6 | consistent-return: error
7 | eqeqeq:
8 | - error
9 | - smart
10 | prefer-arrow-callback: error
11 | globals:
12 | global: readonly
13 |
--------------------------------------------------------------------------------
/logger.js:
--------------------------------------------------------------------------------
1 | import Clutter from 'gi://Clutter';
2 |
3 | const
4 | dbg = false,
5 | tag = 'rx-input-layout-switcher@wzmn.net',
6 | modBitsState = {
7 | CONTROL: 0,
8 | SHIFT: 0,
9 | SUPER: 0,
10 | HYPER: 0,
11 | LOCK: 0, // CAPS LOCK
12 | META: 0,
13 | MOD1: 0, // ALT
14 | MOD2: 0, // NUM LOCK
15 | MOD3: 0,
16 | MOD4: 0, // WinKey
17 | MOD5: 0, // Right ALT
18 | };
19 |
20 | export function printState({ bits, sequence }) {
21 | // for (const mask of Object.keys(modBitsState)) {
22 | // d(`${mask}: ${mods & Clutter.ModifierType[`${mask}_MASK`]}`, Clutter.ModifierType[`${mask}_MASK`], mods);
23 | // }
24 | for (const modName of Object.keys(modBitsState)) {
25 | modBitsState[modName] = bits & Clutter.ModifierType[`${modName}_MASK`];
26 | }
27 |
28 | const activeModsDescription = Object.entries(modBitsState)
29 | .filter(([_, value]) => value).map(([name]) => name)
30 | .join('|');
31 |
32 | d(`${activeModsDescription || 'NONE'} (value: ${bits})`);
33 | d(`modifiersSequence: ${sequence.map(v => v.bits).join('>')}`);
34 | d('');
35 | }
36 |
37 | export function d(message, level = 'debug') {
38 | if ((level === 'debug') && !dbg) {
39 | return; // skip debug prints
40 | }
41 |
42 | console.log(`${tag}: ${message}`);
43 | }
--------------------------------------------------------------------------------
/metadata.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "RX Input Layout Switcher",
3 | "description": "Use Alt+Shift to change the keyboard language",
4 | "uuid": "rx-input-layout-switcher@wzmn.net",
5 | "shell-version": [ "45", "46", "47", "48" ],
6 | "url": "https://github.com/arikw/rx-input-layout-switcher",
7 | "version": 9
8 | }
9 |
--------------------------------------------------------------------------------
/reactive.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2023 Arik W (https://github.com/arikw)
3 | *
4 | * This program is free software: you can redistribute it and/or modify
5 | * it under the terms of the GNU General Public License as published by
6 | * the Free Software Foundation, either version 2 of the License, or
7 | * (at your option) any later version.
8 | *
9 | * This program is distributed in the hope that it will be useful,
10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | * GNU General Public License for more details.
13 | *
14 | * You should have received a copy of the GNU General Public License
15 | * along with this program. If not, see .
16 | *
17 | * SPDX-License-Identifier: GPL-2.0-or-later
18 | */
19 |
20 | const watchersSymbol = Symbol('watchers');
21 |
22 | export function observable(obj) {
23 | const watchers = {};
24 |
25 | const proxy = new Proxy(obj, {
26 | get(target, prop, receiver) {
27 | return Reflect.get(target, prop, receiver);
28 | },
29 | set(target, prop, val, receiver) {
30 | const result = Reflect.set(target, prop, val, receiver);
31 | watchers[prop]?.forEach(cb => cb(val));
32 | return result;
33 | },
34 | });
35 |
36 | proxy[watchersSymbol] = watchers;
37 |
38 | return proxy;
39 | }
40 |
41 | export function watch(obj, prop, cb) {
42 | const watchers = obj[watchersSymbol];
43 | watchers[prop] = watchers[prop] ?? [];
44 | watchers[prop].push(cb);
45 | }
46 |
47 | export function unwatch(obj, prop, cb) {
48 | const watchers = obj[watchersSymbol];
49 | if (!watchers[prop]) {
50 | return;
51 | }
52 | watchers[prop] = watchers[prop].filter(entry => entry !== cb);
53 | if (Object.keys(watchers[prop]).length === 0) {
54 | delete watchers[prop];
55 | }
56 | }
--------------------------------------------------------------------------------