├── .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 | } --------------------------------------------------------------------------------