Compare commits

..

4 Commits

Author SHA1 Message Date
Lucas Oskorep 241f90299c feat: new window gets 1/n ratio, existing windows scale proportionally
Build and Test / build (pull_request) Successful in 25s
Build and Test / release (pull_request) Has been skipped
Previously addWindow() reset all ratios to equal splits, losing any
custom sizing. Now _addRatioForNewWindow() gives the new window 1/n of
the space and scales the existing windows by (n-1)/n, preserving their
ratios relative to each other.

e.g. [1/3, 1/6, 1/2] + new → [0.25, 0.125, 0.375, 0.25]
2026-02-24 19:23:16 -05:00
Lucas Oskorep 7083482d5c fix: min-window-size setting now read live from Gio.Settings on every boundary adjust
Previously _minRatio was baked into WindowContainer at construction time
and never updated. Now adjustBoundary() takes minRatio as a parameter,
WindowManager._getMinRatio() reads it from Gio.Settings on every call,
and the settings reference is injected from extension.ts on enable().
2026-02-24 17:46:20 -05:00
Lucas Oskorep 99778f3ef2 feat: add reset-ratios keybinding and min-window-size setting to prefs UI 2026-02-24 17:33:30 -05:00
Lucas Oskorep 20bac71b45 feat: percentage-based container sizing with live boundary resize
- Add _splitRatios[] to WindowContainer — each child owns a fraction of
  the parent that always sums to 1.0; bounds are computed via prefix-sum
  so the last item absorbs rounding remainder (no pixel gaps)
- addWindow/removeWindow reset to equal splits; itemDragged swaps only
  items (ratios stay slot-based) so windows take the size of the slot
  they move into
- Add adjustBoundary() for live edge-drag resizing clamped to a
  configurable minimum (default 10%, schema key min-window-size-percent)
- Add reset-ratios keybinding (Ctrl+Z) that resets the active container
  to equal splits via WindowManager.resetActiveContainerRatios()
- Connect size-changed signal alongside position-changed so east/south
  edge drags (width-only changes) are detected and the adjacent window
  is repositioned live
- Replace LIFO pop() event queue with name-keyed Map so duplicate events
  collapse to the latest callback and timers never fight each other
- Remove redundant move_frame() before move_resize_frame(); fix retry
  condition to use symmetric pixel tolerance
- Add _isTiling re-entrancy guard around all tileWindows() call sites
  so compositor position-changed callbacks cannot recurse
- Remove double tileWindows() call in monitor.tileWindows() (move()
  already calls it internally)
2026-02-24 17:26:27 -05:00
10 changed files with 666 additions and 165 deletions
+15
View File
@@ -20,6 +20,7 @@ export default class aerospike extends Extension {
enable() {
Logger.log("STARTING AEROSPIKE!")
this.windowManager._settings = this.settings;
this.bindSettings();
this.setupKeybindings();
this.windowManager.enable()
@@ -63,6 +64,11 @@ export default class aerospike extends Extension {
this.refreshKeybinding('toggle-orientation');
});
this.settings.connect('changed::reset-ratios', () => {
log(`Reset ratios keybinding changed to: ${this.settings.get_strv('reset-ratios')}`);
this.refreshKeybinding('reset-ratios');
});
this.settings.connect('changed::dropdown-option', () => {
log(`Dropdown option changed to: ${this.settings.get_string('dropdown-option')}`);
});
@@ -108,6 +114,11 @@ export default class aerospike extends Extension {
this.windowManager.toggleActiveContainerOrientation();
});
break;
case 'reset-ratios':
this.bindKeybinding('reset-ratios', () => {
this.windowManager.resetActiveContainerRatios();
});
break;
}
}
@@ -142,6 +153,10 @@ export default class aerospike extends Extension {
this.bindKeybinding('toggle-orientation', () => {
this.windowManager.toggleActiveContainerOrientation();
});
this.bindKeybinding('reset-ratios', () => {
this.windowManager.resetActiveContainerRatios();
});
}
private bindKeybinding(settingName: string, callback: () => void) {
+1 -1
View File
@@ -25,7 +25,7 @@
"@girs/gnome-shell": "49.1.0",
"@jest/globals": "^30.0.0",
"@types/jest": "^30.0.0",
"eslint": "^10.0.0",
"eslint": "^9.36.0",
"eslint-plugin-jsdoc": "^62.0.0",
"jest": "^30.0.0",
"ts-jest": "^29.1.2",
+141 -80
View File
@@ -43,11 +43,11 @@ importers:
specifier: ^30.0.0
version: 30.0.0
eslint:
specifier: ^10.0.0
version: 10.0.0
specifier: ^9.36.0
version: 9.39.2
eslint-plugin-jsdoc:
specifier: ^62.0.0
version: 62.4.1(eslint@10.0.0)
version: 62.4.1(eslint@9.39.2)
jest:
specifier: ^30.0.0
version: 30.2.0(@types/node@25.1.0)
@@ -252,25 +252,33 @@ packages:
resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==}
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
'@eslint/config-array@0.23.0':
resolution: {integrity: sha512-T5Swqd+PZxBekRuMsIFCySM3NUE8GjuqyksIIsXgkF2GCuiDaqpxKyPkv9VMEKpq5D7r5DLss1tM8tCsvRSjeg==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
'@eslint/config-array@0.21.1':
resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/config-helpers@0.5.2':
resolution: {integrity: sha512-a5MxrdDXEvqnIq+LisyCX6tQMPF/dSJpCfBgBauY+pNZ28yCtSsTvyTYrMhaI+LK26bVyCJfJkT0u8KIj2i1dQ==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
'@eslint/config-helpers@0.4.2':
resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/core@1.1.0':
resolution: {integrity: sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
'@eslint/core@0.17.0':
resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/object-schema@3.0.0':
resolution: {integrity: sha512-nWl20RtHQP2A2yvKU6Fee62Xo1AoNRqBLixtyg45zJhU8ljPFCyBK90d8e8XTnPns1RggSf4HH3bM8AhevkPVg==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
'@eslint/eslintrc@3.3.3':
resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/plugin-kit@0.6.0':
resolution: {integrity: sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
'@eslint/js@9.39.2':
resolution: {integrity: sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/object-schema@2.1.7':
resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/plugin-kit@0.4.1':
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@girs/accountsservice-1.0@1.0.0-4.0.0-beta.38':
resolution: {integrity: sha512-6QzytM5dztmMynF2bxN73EuNK9ArMFxkP2L8wUC7IH45zBeBOfYcqL85BFh2PmkGmqRk+Rli5EFR8dAkx3Ig5Q==}
@@ -417,14 +425,6 @@ packages:
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
engines: {node: '>=18.18'}
'@isaacs/balanced-match@4.0.1':
resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==}
engines: {node: 20 || >=22}
'@isaacs/brace-expansion@5.0.1':
resolution: {integrity: sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==}
engines: {node: 20 || >=22}
'@isaacs/cliui@8.0.2':
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'}
@@ -574,9 +574,6 @@ packages:
'@types/babel__traverse@7.28.0':
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
'@types/esrecurse@4.3.1':
resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==}
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@@ -765,6 +762,9 @@ packages:
argparse@1.0.10:
resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==}
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
babel-jest@30.2.0:
resolution: {integrity: sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
@@ -949,21 +949,25 @@ packages:
peerDependencies:
eslint: ^7.0.0 || ^8.0.0 || ^9.0.0
eslint-scope@9.1.0:
resolution: {integrity: sha512-CkWE42hOJsNj9FJRaoMX9waUFYhqY4jmyLFdAdzZr6VaCg3ynLYx4WnOdkaIifGfH4gsUcBTn4OZbHXkpLD0FQ==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
eslint-scope@8.4.0:
resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
eslint-visitor-keys@3.4.3:
resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
eslint-visitor-keys@4.2.1:
resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
eslint-visitor-keys@5.0.0:
resolution: {integrity: sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
eslint@10.0.0:
resolution: {integrity: sha512-O0piBKY36YSJhlFSG8p9VUdPV/SxxS4FYDWVpr/9GJuMaepzwlf4J8I4ov1b+ySQfDTPhc3DtLaxcT1fN0yqCg==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
eslint@9.39.2:
resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
hasBin: true
peerDependencies:
jiti: '*'
@@ -971,6 +975,10 @@ packages:
jiti:
optional: true
espree@10.4.0:
resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
espree@11.1.0:
resolution: {integrity: sha512-WFWYhO1fV4iYkqOOvq8FbqIhr2pYfoDY0kCotMkDeNtGpiGGkZ1iov2u8ydjtgM8yF8rzK7oaTbw2NAzbAbehw==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
@@ -1077,12 +1085,15 @@ packages:
glob@10.5.0:
resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
hasBin: true
glob@7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
deprecated: Glob versions prior to v9 are no longer supported
globals@14.0.0:
resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
engines: {node: '>=18'}
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
@@ -1110,6 +1121,10 @@ packages:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
import-fresh@3.3.1:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'}
import-local@3.2.0:
resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==}
engines: {node: '>=8'}
@@ -1314,6 +1329,10 @@ packages:
resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==}
hasBin: true
js-yaml@4.1.1:
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
hasBin: true
jsdoc-type-pratt-parser@7.1.0:
resolution: {integrity: sha512-SX7q7XyCwzM/MEDCYz0l8GgGbJAACGFII9+WfNYr5SLEKukHWRy2Jk3iWRe7P+lpYJNs7oQ+OSei4JtKGUjd7A==}
engines: {node: '>=20.0.0'}
@@ -1365,6 +1384,9 @@ packages:
lodash.memoize@4.1.2:
resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==}
lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
@@ -1392,10 +1414,6 @@ packages:
resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==}
engines: {node: '>=6'}
minimatch@10.1.2:
resolution: {integrity: sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==}
engines: {node: 20 || >=22}
minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
@@ -1475,6 +1493,10 @@ packages:
package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
parse-imports-exports@0.2.4:
resolution: {integrity: sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==}
@@ -1550,6 +1572,10 @@ packages:
resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==}
engines: {node: '>=8'}
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
resolve-from@5.0.0:
resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==}
engines: {node: '>=8'}
@@ -2005,34 +2031,50 @@ snapshots:
'@es-joy/resolve.exports@1.2.0': {}
'@eslint-community/eslint-utils@4.9.1(eslint@10.0.0)':
'@eslint-community/eslint-utils@4.9.1(eslint@9.39.2)':
dependencies:
eslint: 10.0.0
eslint: 9.39.2
eslint-visitor-keys: 3.4.3
'@eslint-community/regexpp@4.12.2': {}
'@eslint/config-array@0.23.0':
'@eslint/config-array@0.21.1':
dependencies:
'@eslint/object-schema': 3.0.0
'@eslint/object-schema': 2.1.7
debug: 4.4.3
minimatch: 10.1.2
minimatch: 3.1.2
transitivePeerDependencies:
- supports-color
'@eslint/config-helpers@0.5.2':
'@eslint/config-helpers@0.4.2':
dependencies:
'@eslint/core': 1.1.0
'@eslint/core': 0.17.0
'@eslint/core@1.1.0':
'@eslint/core@0.17.0':
dependencies:
'@types/json-schema': 7.0.15
'@eslint/object-schema@3.0.0': {}
'@eslint/plugin-kit@0.6.0':
'@eslint/eslintrc@3.3.3':
dependencies:
'@eslint/core': 1.1.0
ajv: 6.12.6
debug: 4.4.3
espree: 10.4.0
globals: 14.0.0
ignore: 5.3.2
import-fresh: 3.3.1
js-yaml: 4.1.1
minimatch: 3.1.2
strip-json-comments: 3.1.1
transitivePeerDependencies:
- supports-color
'@eslint/js@9.39.2': {}
'@eslint/object-schema@2.1.7': {}
'@eslint/plugin-kit@0.4.1':
dependencies:
'@eslint/core': 0.17.0
levn: 0.4.1
'@girs/accountsservice-1.0@1.0.0-4.0.0-beta.38':
@@ -2499,12 +2541,6 @@ snapshots:
'@humanwhocodes/retry@0.4.3': {}
'@isaacs/balanced-match@4.0.1': {}
'@isaacs/brace-expansion@5.0.1':
dependencies:
'@isaacs/balanced-match': 4.0.1
'@isaacs/cliui@8.0.2':
dependencies:
string-width: 5.1.2
@@ -2772,8 +2808,6 @@ snapshots:
dependencies:
'@babel/types': 7.28.6
'@types/esrecurse@4.3.1': {}
'@types/estree@1.0.8': {}
'@types/istanbul-lib-coverage@2.0.6': {}
@@ -2908,6 +2942,8 @@ snapshots:
dependencies:
sprintf-js: 1.0.3
argparse@2.0.1: {}
babel-jest@30.2.0(@babel/core@7.28.6):
dependencies:
'@babel/core': 7.28.6
@@ -3074,7 +3110,7 @@ snapshots:
escape-string-regexp@4.0.0: {}
eslint-plugin-jsdoc@62.4.1(eslint@10.0.0):
eslint-plugin-jsdoc@62.4.1(eslint@9.39.2):
dependencies:
'@es-joy/jsdoccomment': 0.83.0
'@es-joy/resolve.exports': 1.2.0
@@ -3082,7 +3118,7 @@ snapshots:
comment-parser: 1.4.5
debug: 4.4.3
escape-string-regexp: 4.0.0
eslint: 10.0.0
eslint: 9.39.2
espree: 11.1.0
esquery: 1.7.0
html-entities: 2.6.0
@@ -3094,36 +3130,39 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-scope@9.1.0:
eslint-scope@8.4.0:
dependencies:
'@types/esrecurse': 4.3.1
'@types/estree': 1.0.8
esrecurse: 4.3.0
estraverse: 5.3.0
eslint-visitor-keys@3.4.3: {}
eslint-visitor-keys@4.2.1: {}
eslint-visitor-keys@5.0.0: {}
eslint@10.0.0:
eslint@9.39.2:
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.0.0)
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2)
'@eslint-community/regexpp': 4.12.2
'@eslint/config-array': 0.23.0
'@eslint/config-helpers': 0.5.2
'@eslint/core': 1.1.0
'@eslint/plugin-kit': 0.6.0
'@eslint/config-array': 0.21.1
'@eslint/config-helpers': 0.4.2
'@eslint/core': 0.17.0
'@eslint/eslintrc': 3.3.3
'@eslint/js': 9.39.2
'@eslint/plugin-kit': 0.4.1
'@humanfs/node': 0.16.7
'@humanwhocodes/module-importer': 1.0.1
'@humanwhocodes/retry': 0.4.3
'@types/estree': 1.0.8
ajv: 6.12.6
chalk: 4.1.2
cross-spawn: 7.0.6
debug: 4.4.3
escape-string-regexp: 4.0.0
eslint-scope: 9.1.0
eslint-visitor-keys: 5.0.0
espree: 11.1.0
eslint-scope: 8.4.0
eslint-visitor-keys: 4.2.1
espree: 10.4.0
esquery: 1.7.0
esutils: 2.0.3
fast-deep-equal: 3.1.3
@@ -3134,12 +3173,19 @@ snapshots:
imurmurhash: 0.1.4
is-glob: 4.0.3
json-stable-stringify-without-jsonify: 1.0.1
minimatch: 10.1.2
lodash.merge: 4.6.2
minimatch: 3.1.2
natural-compare: 1.4.0
optionator: 0.9.4
transitivePeerDependencies:
- supports-color
espree@10.4.0:
dependencies:
acorn: 8.15.0
acorn-jsx: 5.3.2(acorn@8.15.0)
eslint-visitor-keys: 4.2.1
espree@11.1.0:
dependencies:
acorn: 8.15.0
@@ -3258,6 +3304,8 @@ snapshots:
once: 1.4.0
path-is-absolute: 1.0.1
globals@14.0.0: {}
graceful-fs@4.2.11: {}
handlebars@4.7.8:
@@ -3279,6 +3327,11 @@ snapshots:
ignore@5.3.2: {}
import-fresh@3.3.1:
dependencies:
parent-module: 1.0.1
resolve-from: 4.0.0
import-local@3.2.0:
dependencies:
pkg-dir: 4.2.0
@@ -3666,6 +3719,10 @@ snapshots:
argparse: 1.0.10
esprima: 4.0.1
js-yaml@4.1.1:
dependencies:
argparse: 2.0.1
jsdoc-type-pratt-parser@7.1.0: {}
jsesc@3.1.0: {}
@@ -3703,6 +3760,8 @@ snapshots:
lodash.memoize@4.1.2: {}
lodash.merge@4.6.2: {}
lru-cache@10.4.3: {}
lru-cache@5.1.1:
@@ -3728,10 +3787,6 @@ snapshots:
mimic-fn@2.1.0: {}
minimatch@10.1.2:
dependencies:
'@isaacs/brace-expansion': 5.0.1
minimatch@3.1.2:
dependencies:
brace-expansion: 1.1.12
@@ -3801,6 +3856,10 @@ snapshots:
package-json-from-dist@1.0.1: {}
parent-module@1.0.1:
dependencies:
callsites: 3.1.0
parse-imports-exports@0.2.4:
dependencies:
parse-statements: 1.0.11
@@ -3859,6 +3918,8 @@ snapshots:
dependencies:
resolve-from: 5.0.0
resolve-from@4.0.0: {}
resolve-from@5.0.0: {}
semver@6.3.1: {}
@@ -49,5 +49,18 @@
<description>Toggles the orientation of the container holding the active window between horizontal and vertical</description>
</key>
<key name="reset-ratios" type="as">
<default><![CDATA[['<Primary>z']]]></default>
<summary>Reset container ratios to equal splits</summary>
<description>Resets all window size ratios in the active window's container to equal splits</description>
</key>
<key name="min-window-size-percent" type="d">
<default>0.10</default>
<range min="0.01" max="0.49"/>
<summary>Minimum window size percentage</summary>
<description>Minimum fraction of a container that any single window may occupy when resizing boundaries</description>
</key>
</schema>
</schemalist>
+43
View File
@@ -173,6 +173,49 @@ export default class AerospikeExtensions extends ExtensionPreferences {
})
);
keybindingsGroup.add(
new EntryRow({
title: _('Reset Container Ratios to Equal'),
settings: settings,
bind: 'reset-ratios',
map: keybindingMap
})
);
// Create sizing group
const sizingGroup = new Adw.PreferencesGroup({
title: _('Window Sizing'),
});
page.add(sizingGroup);
// Minimum window size percentage spinner
const minSizeRow = new Adw.ActionRow({
title: _('Minimum Window Size'),
subtitle: _('Smallest fraction of a container any window may occupy when resizing (e.g. 0.10 = 10%)'),
});
sizingGroup.add(minSizeRow);
const minSizeSpin = new Gtk.SpinButton({
adjustment: new Gtk.Adjustment({
lower: 0.01,
upper: 0.49,
step_increment: 0.01,
page_increment: 0.05,
value: settings.get_double('min-window-size-percent'),
}),
digits: 2,
valign: Gtk.Align.CENTER,
});
minSizeRow.add_suffix(minSizeSpin);
minSizeRow.set_activatable_widget(minSizeSpin);
minSizeSpin.connect('value-changed', () => {
settings.set_double('min-window-size-percent', minSizeSpin.get_value());
});
settings.connect('changed::min-window-size-percent', () => {
minSizeSpin.set_value(settings.get_double('min-window-size-percent'));
});
}
+14 -6
View File
@@ -6,15 +6,23 @@ export type QueuedEvent = {
callback: () => void;
}
const queuedEvents: QueuedEvent[] = [];
// Pending events indexed by name so that duplicate events collapse into one.
// Only the most-recently-queued callback for a given name is kept.
const pendingEvents: Map<string, QueuedEvent> = new Map();
export default function queueEvent(event: QueuedEvent, interval = 200) {
queuedEvents.push(event);
// Overwrite any earlier pending event with the same name — the latest
// callback is always the most up-to-date one.
pendingEvents.set(event.name, event);
GLib.timeout_add(GLib.PRIORITY_DEFAULT, interval, () => {
const e = queuedEvents.pop()
if (e) {
const e = pendingEvents.get(event.name);
if (e && e === event) {
// Only fire if this is still the current callback for this name
// (a newer call may have replaced it).
pendingEvents.delete(event.name);
e.callback();
}
return queuedEvents.length !== 0;
return GLib.SOURCE_REMOVE;
});
}
}
+210 -39
View File
@@ -9,6 +9,21 @@ enum Orientation {
VERTICAL = 1,
}
/**
* Build a split-ratio array of length `n` where every element equals 1/n,
* with the last slot absorbing any floating-point remainder so the array
* always sums to exactly 1.0.
*/
function equalRatios(n: number): number[] {
if (n <= 0) return [];
const base = 1 / n;
const ratios = Array(n).fill(base);
// Fix floating-point drift: make last slot exact
const sumExceptLast = ratios.slice(0, -1).reduce((a, b) => a + b, 0);
ratios[n - 1] = 1 - sumExceptLast;
return ratios;
}
export default class WindowContainer {
@@ -17,12 +32,60 @@ export default class WindowContainer {
_orientation: Orientation = Orientation.HORIZONTAL;
_workArea: Rect;
constructor(workspaceArea: Rect,) {
/**
* Per-child split ratios. Always satisfies:
* _splitRatios.length === _tiledItems.length
* _splitRatios.reduce((a,b) => a+b, 0) === 1.0 (within floating-point epsilon)
* every element >= MIN_RATIO
*/
_splitRatios: number[];
constructor(workspaceArea: Rect) {
this._tiledItems = [];
this._tiledWindowLookup = new Map<number, WindowWrapper>();
this._workArea = workspaceArea;
this._splitRatios = [];
}
// ─── Helpers ────────────────────────────────────────────────────────────────
/** Rebuild _splitRatios as equal fractions after any structural change. */
private _resetRatios(): void {
this._splitRatios = equalRatios(this._tiledItems.length);
}
/**
* Called after a new item has been pushed onto _tiledItems.
* The new window (last slot) gets 1/n of the space; existing windows
* are scaled down proportionally so their ratios relative to each other
* are preserved and the total remains 1.0.
*
* e.g. [0.33, 0.166, 0.5] + new → new=0.25, existing scaled by 0.75
* → [0.2475, 0.1245, 0.375, 0.25]
*/
private _addRatioForNewWindow(): void {
const n = this._tiledItems.length;
if (n <= 1) {
this._splitRatios = [1.0];
return;
}
const newRatio = 1 / n;
const scale = 1 - newRatio; // existing windows share this fraction
const scaled = this._splitRatios.map(r => r * scale);
// Absorb all floating-point drift into the last slot so sum is exactly 1.0
const partialSum = scaled.reduce((a, b) => a + b, 0) + newRatio;
scaled[scaled.length - 1] += (1.0 - partialSum);
this._splitRatios = [...scaled, newRatio];
}
/** Total dimension for the active orientation (width for H, height for V). */
private _totalDimension(): number {
return this._orientation === Orientation.HORIZONTAL
? this._workArea.width
: this._workArea.height;
}
// ─── Public API ─────────────────────────────────────────────────────────────
move(rect: Rect): void {
this._workArea = rect;
@@ -40,13 +103,13 @@ export default class WindowContainer {
addWindow(winWrap: WindowWrapper): void {
this._tiledItems.push(winWrap);
this._tiledWindowLookup.set(winWrap.getWindowId(), winWrap);
this._addRatioForNewWindow();
queueEvent({
name: "tiling-windows",
callback: () => {
this.tileWindows();
}
}, 100)
}, 100);
}
getWindow(win_id: number): WindowWrapper | undefined {
@@ -63,27 +126,27 @@ export default class WindowContainer {
return item;
}
}
return undefined
return undefined;
}
_getIndexOfWindow(win_id: number) {
_getIndexOfWindow(win_id: number): number {
for (let i = 0; i < this._tiledItems.length; i++) {
const item = this._tiledItems[i];
if (item instanceof WindowWrapper && item.getWindowId() === win_id) {
return i;
}
}
return -1
return -1;
}
removeWindow(win_id: number): void {
if (this._tiledWindowLookup.has(win_id)) {
// Get index before deleting from lookup to avoid race condition
const index = this._getIndexOfWindow(win_id);
this._tiledWindowLookup.delete(win_id);
if (index !== -1) {
this._tiledItems.splice(index, 1);
}
this._resetRatios();
} else {
for (const item of this._tiledItems) {
if (item instanceof WindowContainer) {
@@ -91,33 +154,30 @@ export default class WindowContainer {
}
}
}
this.tileWindows()
this.tileWindows();
}
disconnectSignals(): void {
this._tiledItems.forEach((item) => {
if (item instanceof WindowContainer) {
item.disconnectSignals()
} else {
item.disconnectWindowSignals();
}
if (item instanceof WindowContainer) {
item.disconnectSignals();
} else {
item.disconnectWindowSignals();
}
)
});
}
removeAllWindows(): void {
this._tiledItems = []
this._tiledWindowLookup.clear()
this._tiledItems = [];
this._tiledWindowLookup.clear();
this._splitRatios = [];
}
tileWindows() {
Logger.log("TILING WINDOWS IN CONTAINER")
Logger.log("TILING WINDOWS IN CONTAINER");
Logger.log("WorkArea", this._workArea);
this._tileItems()
return true
this._tileItems();
return true;
}
_tileItems() {
@@ -125,16 +185,19 @@ export default class WindowContainer {
return;
}
const bounds = this.getBounds();
Logger.info(`_tileItems: ratios=[${this._splitRatios.map(r => r.toFixed(3)).join(', ')}] bounds=[${bounds.map(b => `(${b.x},${b.y},${b.width},${b.height})`).join(', ')}]`);
this._tiledItems.forEach((item, index) => {
const rect = bounds[index];
if (item instanceof WindowContainer) {
item.move(rect);
} else {
Logger.info(`_tileItems: window[${index}] id=${item.getWindowId()} dragging=${item._dragging} → rect=(${rect.x},${rect.y},${rect.width},${rect.height})`);
item.safelyResizeWindow(rect);
}
})
});
}
// ─── Bounds Calculation ──────────────────────────────────────────────────────
getBounds(): Rect[] {
if (this._orientation === Orientation.HORIZONTAL) {
@@ -144,33 +207,130 @@ export default class WindowContainer {
}
getVerticalBounds(): Rect[] {
const items = this._tiledItems
const containerHeight = Math.floor(this._workArea.height / items.length);
const items = this._tiledItems;
const totalHeight = this._workArea.height;
let usedHeight = 0;
return items.map((_, index) => {
const y = this._workArea.y + (index * containerHeight);
const y = this._workArea.y + usedHeight;
let height: number;
if (index === items.length - 1) {
// Last item gets the remainder to avoid pixel gaps from rounding
height = totalHeight - usedHeight;
} else {
height = Math.floor(this._splitRatios[index] * totalHeight);
}
usedHeight += height;
return {
x: this._workArea.x,
y: y,
width: this._workArea.width,
height: containerHeight
height: height,
} as Rect;
});
}
getHorizontalBounds(): Rect[] {
const windowWidth = Math.floor(this._workArea.width / this._tiledItems.length);
const totalWidth = this._workArea.width;
let usedWidth = 0;
return this._tiledItems.map((_, index) => {
const x = this._workArea.x + (index * windowWidth);
const x = this._workArea.x + usedWidth;
let width: number;
if (index === this._tiledItems.length - 1) {
// Last item gets the remainder to avoid pixel gaps from rounding
width = totalWidth - usedWidth;
} else {
width = Math.floor(this._splitRatios[index] * totalWidth);
}
usedWidth += width;
return {
x: x,
y: this._workArea.y,
width: windowWidth,
height: this._workArea.height
width: width,
height: this._workArea.height,
} as Rect;
});
}
// ─── Boundary / Ratio Adjustment ─────────────────────────────────────────────
/**
* Adjust the boundary between item[boundaryIndex] and item[boundaryIndex+1]
* by deltaPixels (positive = move right/down, negative = move left/up).
*
* Both affected ratios are clamped to [_minRatio, 1 - _minRatio] so no
* window can be squashed below the configured minimum.
*
* Returns true if the adjustment was applied, false if it was rejected
* (e.g. out of bounds index or clamping would violate minimum).
*/
adjustBoundary(boundaryIndex: number, deltaPixels: number, minRatio: number = 0.10): boolean {
if (boundaryIndex < 0 || boundaryIndex >= this._tiledItems.length - 1) {
Logger.warn(`adjustBoundary: invalid boundaryIndex ${boundaryIndex}`);
return false;
}
const totalDim = this._totalDimension();
if (totalDim === 0) return false;
const ratioDelta = deltaPixels / totalDim;
const newLeft = this._splitRatios[boundaryIndex] + ratioDelta;
const newRight = this._splitRatios[boundaryIndex + 1] - ratioDelta;
if (newLeft < minRatio || newRight < minRatio) {
Logger.log(`adjustBoundary: clamped — newLeft=${newLeft.toFixed(3)}, newRight=${newRight.toFixed(3)}, min=${minRatio}`);
return false;
}
this._splitRatios[boundaryIndex] = newLeft;
this._splitRatios[boundaryIndex + 1] = newRight;
Logger.info(`adjustBoundary: boundary=${boundaryIndex} ratios=[${this._splitRatios.map(r => r.toFixed(3)).join(', ')}]`);
return true;
}
/**
* Adjust boundaries on BOTH axes simultaneously for corner resize ops.
* horizontalDelta applies to this container if HORIZONTAL, verticalDelta if VERTICAL.
* For nested containers the perpendicular delta is forwarded to the child container.
*
* boundaryIndex: the slot index whose right/bottom edge is being dragged.
*/
adjustBoundaryBothAxes(
boundaryIndex: number,
horizontalDelta: number,
verticalDelta: number,
): void {
if (this._orientation === Orientation.HORIZONTAL) {
this.adjustBoundary(boundaryIndex, horizontalDelta);
} else {
this.adjustBoundary(boundaryIndex, verticalDelta);
}
}
// ─── Container Lookup ────────────────────────────────────────────────────────
/**
* Returns the direct-parent WindowContainer that contains win_id as an
* immediate child (not recursed further). Returns null if not found.
*/
getContainerForWindow(win_id: number): WindowContainer | null {
for (const item of this._tiledItems) {
if (item instanceof WindowWrapper && item.getWindowId() === win_id) {
return this;
}
}
for (const item of this._tiledItems) {
if (item instanceof WindowContainer) {
const found = item.getContainerForWindow(win_id);
if (found !== null) return found;
}
}
return null;
}
getIndexOfItemNested(item: WindowWrapper): number {
for (let i = 0; i < this._tiledItems.length; i++) {
const container = this._tiledItems[i];
@@ -194,19 +354,30 @@ export default class WindowContainer {
Logger.error("Item not found in container during drag op", item.getWindowId());
return;
}
let new_index = this.getIndexOfItemNested(item);
let new_index = original_index;
this.getBounds().forEach((rect, index) => {
if (rect.x < x && rect.x + rect.width > x && rect.y < y && rect.y + rect.height > y) {
new_index = index;
}
})
});
if (original_index !== new_index) {
this._tiledItems.splice(original_index, 1);
this._tiledItems.splice(new_index, 0, item);
this.tileWindows()
// Swap only the items — ratios stay with their slots.
// e.g. slot 0 = 40%, slot 1 = 60%: when the window in slot 1 drags
// into slot 0, it takes slot 0's 40% size. The window it displaces
// moves to slot 1 and takes the 60% size. The slot ratios are unchanged.
[this._tiledItems[original_index], this._tiledItems[new_index]] =
[this._tiledItems[new_index], this._tiledItems[original_index]];
Logger.info(`itemDragged: swapped slots ${original_index}<->${new_index}, ratios=[${this._splitRatios.map(r => r.toFixed(3)).join(', ')}]`);
this.tileWindows();
}
}
}
/**
* Reset all split ratios in this container to equal fractions.
* Called when the user explicitly requests an equal-split reset (e.g. Ctrl+Z).
*/
resetRatios(): void {
this._resetRatios();
this.tileWindows();
}
}
+1 -1
View File
@@ -67,8 +67,8 @@ export default class Monitor {
tileWindows(): void {
this._workArea = global.workspace_manager.get_active_workspace().get_work_area_for_monitor(this._id);
const activeWorkspace = global.workspace_manager.get_active_workspace();
// move() already calls tileWindows() internally — don't call it again
this._workspaces[activeWorkspace.index()].move(this._workArea);
this._workspaces[activeWorkspace.index()].tileWindows()
}
removeWorkspace(workspaceId: number): void {
+26 -17
View File
@@ -99,6 +99,9 @@ export class WindowWrapper {
this._window.connect("position-changed", (_metaWindow) => {
windowManager.handleWindowPositionChanged(this);
}),
this._window.connect("size-changed", (_metaWindow) => {
windowManager.handleWindowPositionChanged(this);
}),
);
}
@@ -117,35 +120,41 @@ export class WindowWrapper {
}
}
safelyResizeWindow(rect: Rect, _retry: number = 2): void {
// Keep minimal logging
safelyResizeWindow(rect: Rect, _retry: number = 3): void {
if (this._dragging) {
Logger.info("STOPPED RESIZE BECAUSE ITEM IS BEING DRAGGED")
Logger.info("STOPPED RESIZE BECAUSE ITEM IS BEING DRAGGED");
return;
}
// Logger.log("SAFELY RESIZE", rect.x, rect.y, rect.width, rect.height);
const actor = this._window.get_compositor_private();
const actor = this._window.get_compositor_private() as Clutter.Actor | null;
if (!actor) {
Logger.log("No actor available, can't resize safely yet");
return;
}
let windowActor = this._window.get_compositor_private() as Clutter.Actor;
if (!windowActor) return;
windowActor.remove_all_transitions();
// Logger.info("MOVING")
this._window.move_frame(true, rect.x, rect.y);
// Logger.info("RESIZING MOVING")
actor.remove_all_transitions();
// Single call: move + resize atomically
this._window.move_resize_frame(true, rect.x, rect.y, rect.width, rect.height);
let new_rect = this._window.get_frame_rect();
if ( _retry > 0 && (new_rect.x != rect.x || rect.y != new_rect.y || rect.width < new_rect.width || rect.height < new_rect.height)) {
Logger.warn("RESIZING FAILED AS SMALLER", new_rect.x, new_rect.y, new_rect.width, new_rect.height, rect.x, rect.y, rect.width, rect.height);
const new_rect = this._window.get_frame_rect();
const TOLERANCE = 2; // pixels — allow compositor rounding
const mismatch =
Math.abs(new_rect.x - rect.x) > TOLERANCE ||
Math.abs(new_rect.y - rect.y) > TOLERANCE ||
Math.abs(new_rect.width - rect.width) > TOLERANCE ||
Math.abs(new_rect.height - rect.height) > TOLERANCE;
if (_retry > 0 && mismatch) {
Logger.warn("RESIZE MISMATCH, retrying",
`want(${rect.x},${rect.y},${rect.width},${rect.height})`,
`got(${new_rect.x},${new_rect.y},${new_rect.width},${new_rect.height})`);
queueEvent({
name: "attempting_delayed_resize",
name: `delayed_resize_${this.getWindowId()}`,
callback: () => {
this.safelyResizeWindow(rect, _retry-1);
this.safelyResizeWindow(rect, _retry - 1);
}
})
}, 50);
}
}
+202 -21
View File
@@ -1,5 +1,5 @@
import Meta from "gi://Meta";
// import Gio from "gi://Gio";
import Gio from "gi://Gio";
// import GLib from "gi://GLib";
import {WindowWrapper} from './window.js';
@@ -8,6 +8,7 @@ import * as Main from "resource:///org/gnome/shell/ui/main.js";
import {Logger} from "../utils/logger.js";
import Monitor from "./monitor.js";
import WindowContainer from "./container.js";
import {Rect} from "../utils/rect.js";
export interface IWindowManager {
@@ -49,9 +50,23 @@ export default class WindowManager implements IWindowManager {
_showingOverview: boolean = false;
constructor() {
// ── Resize-drag tracking ──────────────────────────────────────────────────
_isResizeDrag: boolean = false;
_resizeDragWindowId: number = _UNUSED_WINDOW_ID;
_resizeDragOp: Meta.GrabOp = Meta.GrabOp.NONE;
/** Mouse position at the start of each incremental resize step. */
_resizeDragLastMouseX: number = 0;
_resizeDragLastMouseY: number = 0;
/** Re-entrancy guard: true while tileWindows is propagating position-changed events. */
_isTiling: boolean = false;
_settings: Gio.Settings | null = null;
constructor() {}
/** Returns the live min-ratio value from settings, falling back to 0.10. */
private _getMinRatio(): number {
return this._settings?.get_double('min-window-size-percent') ?? 0.10;
}
public enable(): void {
@@ -208,25 +223,65 @@ export default class WindowManager implements IWindowManager {
}
handleGrabOpBegin(display: Meta.Display, window: Meta.Window, op: Meta.GrabOp): void {
if (op === Meta.GrabOp.MOVING_UNCONSTRAINED){
/**
* Returns true if the grab op is a resize operation (any edge or corner).
*/
_isResizeOp(op: Meta.GrabOp): boolean {
return op === Meta.GrabOp.RESIZING_E ||
op === Meta.GrabOp.RESIZING_W ||
op === Meta.GrabOp.RESIZING_N ||
op === Meta.GrabOp.RESIZING_S ||
op === Meta.GrabOp.RESIZING_NE ||
op === Meta.GrabOp.RESIZING_NW ||
op === Meta.GrabOp.RESIZING_SE ||
op === Meta.GrabOp.RESIZING_SW;
}
}
handleGrabOpBegin(display: Meta.Display, window: Meta.Window, op: Meta.GrabOp): void {
Logger.log("Grab Op Start", op);
Logger.log(display, window, op)
Logger.log(window.get_monitor())
this._getWrappedWindow(window)?.startDragging();
this._grabbedWindowMonitor = window.get_monitor();
this._grabbedWindowId = window.get_id();
if (this._isResizeOp(op)) {
// ── Resize drag ──────────────────────────────────────────────────
Logger.log("Resize drag begin, op=", op);
this._isResizeDrag = true;
this._resizeDragWindowId = window.get_id();
this._resizeDragOp = op;
const [startMouseX, startMouseY] = global.get_pointer();
this._resizeDragLastMouseX = startMouseX;
this._resizeDragLastMouseY = startMouseY;
// Mark the window as dragging so safelyResizeWindow skips it while
// we tile the other windows in response to ratio changes.
this._getWrappedWindow(window)?.startDragging();
} else {
// ── Move drag (existing behaviour) ───────────────────────────────
this._getWrappedWindow(window)?.startDragging();
this._grabbedWindowMonitor = window.get_monitor();
this._grabbedWindowId = window.get_id();
}
}
handleGrabOpEnd(display: Meta.Display, window: Meta.Window, op: Meta.GrabOp): void {
Logger.log("Grab Op End ", op);
Logger.log("primary display", display.get_primary_monitor())
this._grabbedWindowId = _UNUSED_WINDOW_ID;
this._getWrappedWindow(window)?.stopDragging();
this._tileMonitors();
Logger.info("monitor_start and monitor_end", this._grabbedWindowMonitor, window.get_monitor());
if (this._isResizeDrag) {
// ── Resize drag end ──────────────────────────────────────────────
Logger.log("Resize drag end, op=", op);
this._isResizeDrag = false;
this._resizeDragWindowId = _UNUSED_WINDOW_ID;
this._resizeDragLastMouseX = 0;
this._resizeDragLastMouseY = 0;
this._resizeDragOp = Meta.GrabOp.NONE;
// Stop suppressing the window, then snap everything to computed ratios
this._getWrappedWindow(window)?.stopDragging();
this._tileMonitors();
} else {
// ── Move drag end (existing behaviour) ───────────────────────────
Logger.log("primary display", display.get_primary_monitor())
this._grabbedWindowId = _UNUSED_WINDOW_ID;
this._getWrappedWindow(window)?.stopDragging();
this._tileMonitors();
Logger.info("monitor_start and monitor_end", this._grabbedWindowMonitor, window.get_monitor());
}
}
_getWrappedWindow(window: Meta.Window): WindowWrapper | undefined {
@@ -265,9 +320,21 @@ export default class WindowManager implements IWindowManager {
}
public handleWindowPositionChanged(winWrap: WindowWrapper): void {
// Ignore position changes that we triggered ourselves via tileWindows
if (this._isTiling) {
return;
}
if (this._changingGrabbedMonitor) {
return;
}
// ── Live resize-drag handling ─────────────────────────────────────────
if (this._isResizeDrag && winWrap.getWindowId() === this._resizeDragWindowId) {
this._handleResizeDragUpdate(winWrap);
return;
}
// ── Move-drag handling (existing behaviour) ───────────────────────────
if (winWrap.getWindowId() === this._grabbedWindowId) {
const [mouseX, mouseY, _] = global.get_pointer();
@@ -281,18 +348,109 @@ export default class WindowManager implements IWindowManager {
}
}
if (monitorIndex === -1) {
return
return;
}
if (monitorIndex !== this._grabbedWindowMonitor) {
this._changingGrabbedMonitor = true;
this._moveWindowToMonitor(winWrap.getWindow(), monitorIndex);
this._changingGrabbedMonitor = false
this._changingGrabbedMonitor = false;
}
// Guard _isTiling so that tileWindows() calls triggered by itemDragged
// (which repositions the displaced window) don't re-enter this handler.
this._isTiling = true;
try {
this._monitors.get(monitorIndex)?.itemDragged(winWrap, mouseX, mouseY);
} finally {
this._isTiling = false;
}
this._monitors.get(monitorIndex)?.itemDragged(winWrap, mouseX, mouseY);
}
}
/**
* Called on every position-changed event while a resize drag is in progress.
* Computes the pixel delta from the drag-start rect, maps it to the correct
* container boundary, and calls adjustBoundary() for live feedback.
*/
private _handleResizeDragUpdate(winWrap: WindowWrapper): void {
const op = this._resizeDragOp;
const winId = winWrap.getWindowId();
// Read the current mouse position — this is unclamped by the compositor
// and always reflects the true user intent, unlike the window's frame rect
// which gets clamped when adjacent windows block expansion.
const [mouseX, mouseY] = global.get_pointer();
const dx = mouseX - this._resizeDragLastMouseX;
const dy = mouseY - this._resizeDragLastMouseY;
if (dx === 0 && dy === 0) return;
// Update last position first so even if we return early the baseline advances
this._resizeDragLastMouseX = mouseX;
this._resizeDragLastMouseY = mouseY;
// Find the container that directly holds this window
const container = this._findContainerForWindowAcrossMonitors(winId);
if (!container) {
Logger.warn("_handleResizeDragUpdate: no container found for window", winId);
return;
}
const itemIndex = container._getIndexOfWindow(winId);
if (itemIndex === -1) return;
const isHorizontal = container._orientation === 0; // Orientation.HORIZONTAL
// Map the mouse delta to the correct boundary.
//
// East/South edge → boundary AFTER the item (boundaryIndex = itemIndex)
// positive dx/dy grows this item, shrinks the next one.
// West/North edge → boundary BEFORE the item (boundaryIndex = itemIndex - 1)
// positive dx/dy moves the left edge right, growing the left neighbour
// and shrinking this item — so we negate the delta.
const minRatio = this._getMinRatio();
let adjusted = false;
if (isHorizontal) {
if (op === Meta.GrabOp.RESIZING_E || op === Meta.GrabOp.RESIZING_NE || op === Meta.GrabOp.RESIZING_SE) {
adjusted = container.adjustBoundary(itemIndex, dx, minRatio);
} else if (op === Meta.GrabOp.RESIZING_W || op === Meta.GrabOp.RESIZING_NW || op === Meta.GrabOp.RESIZING_SW) {
adjusted = container.adjustBoundary(itemIndex - 1, dx, minRatio);
}
} else {
if (op === Meta.GrabOp.RESIZING_S || op === Meta.GrabOp.RESIZING_SE || op === Meta.GrabOp.RESIZING_SW) {
adjusted = container.adjustBoundary(itemIndex, dy, minRatio);
} else if (op === Meta.GrabOp.RESIZING_N || op === Meta.GrabOp.RESIZING_NE || op === Meta.GrabOp.RESIZING_NW) {
adjusted = container.adjustBoundary(itemIndex - 1, dy, minRatio);
}
}
// Tile all windows with the updated ratios, guarded so the resulting
// position-changed events don't re-enter this handler.
if (adjusted) {
this._isTiling = true;
try {
container.tileWindows();
} finally {
this._isTiling = false;
}
}
}
/**
* Searches all monitors for the WindowContainer that directly holds win_id.
*/
private _findContainerForWindowAcrossMonitors(winId: number): WindowContainer | null {
const activeWorkspaceIndex = global.workspace_manager.get_active_workspace().index();
for (const monitor of this._monitors.values()) {
if (activeWorkspaceIndex >= monitor._workspaces.length) continue;
const workspace = monitor._workspaces[activeWorkspaceIndex];
const container = workspace.getContainerForWindow(winId);
if (container !== null) return container;
}
return null;
}
public handleWindowMinimized(winWrap: WindowWrapper): void {
const monitor_id = winWrap.getWindow().get_monitor()
@@ -372,9 +530,13 @@ export default class WindowManager implements IWindowManager {
}
_tileMonitors(): void {
for (const monitor of this._monitors.values()) {
monitor.tileWindows()
this._isTiling = true;
try {
for (const monitor of this._monitors.values()) {
monitor.tileWindows();
}
} finally {
this._isTiling = false;
}
}
@@ -455,6 +617,25 @@ export default class WindowManager implements IWindowManager {
}
}
/**
* Resets all split ratios in the active window's container to equal fractions.
* Bound to Ctrl+Z by default.
*/
public resetActiveContainerRatios(): void {
if (this._activeWindowId === null) {
Logger.warn("No active window, cannot reset container ratios");
return;
}
const activeContainer = this._findActiveContainer();
if (activeContainer) {
Logger.info("Resetting container ratios to equal splits");
activeContainer.resetRatios();
} else {
Logger.warn("Could not find container for active window");
}
}
/**
* Finds the container that directly contains the active window
* @returns The container holding the active window, or null if not found