Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/run-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ jobs:
node-version: "*"
- name: Prepare Linters
run: npm i
- name: Run Node tests
run: npm test
- name: Run ESLint (*.js)
run: >
git diff --name-only --diff-filter=ACMTUXB origin/${{ github.base_ref }} HEAD |
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## Unreleased

### Added

- Add an optional Edge Cycle dynamic keybinding behavior that cycles full-height left/right tiles through half, third, and quarter widths.

## [54] - 2026-02-03

### Added
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@ Tiling Assistant is a GNOME Shell extension which adds a Windows-like snap assis

Please visit the [wiki](https://github.com/Leleat/Tiling-Assistant/wiki) for a list of all features. You'll also find videos and explanations for each of them there.

### Edge Cycle

The optional **Edge Cycle** dynamic keybinding behavior cycles a full-height window at the left or right edge through equal half, third, and quarter widths. Repeatedly press the same horizontal tile shortcut to cycle through the states:

```text
Super+Left: left half -> left third -> left quarter -> left half
Super+Right: right half -> right third -> right quarter -> right half
```

Enable it under **Preferences → General → Dynamic Keybinding Behavior**. Existing dynamic keybinding modes remain unchanged by default.

The existing `tile-maximize-vertically` shortcut can be assigned to `Super+Up` if vertical-only maximization is desired. Clear the `tile-maximize` binding first to avoid a shortcut conflict.

## Supported GNOME Versions

The [metadata](https://github.com/Leleat/Tiling-Assistant/blob/main/tiling-assistant%40leleat-on-github/metadata.json#L4) file lists all currently supported GNOME Shell versions. Generally, only the most recent GNOME Shell is supported. That means older releases may not include all features and bug fixes. You can look at the revisions of the wiki articles to find out when a feature was added, changed, or improved. The [changelog](https://github.com/Leleat/Tiling-Assistant/blob/main/CHANGELOG.md) will show all changes in chronological order.
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
"private": true,
"homepage": "https://github.com/Leleat/Tiling-Assistant",
"main": "tiling-assistant@leleat-on-github/extension.js",
"scripts": {},
"scripts": {
"test": "node --test test/*.test.js"
},
"author": "Leleat",
"license": "GPL-2.0-or-later",
"type": "module",
Expand Down
101 changes: 101 additions & 0 deletions test/edgeCycle.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import test from 'node:test';
import assert from 'node:assert/strict';

import {
DEFAULT_EDGE_DIVISIONS,
detectEdgeDivision,
isFullHeightEdgeRect,
makeEdgeRect,
nextDivision
} from '../tiling-assistant@leleat-on-github/src/extension/edgeCycle.js';

const workArea = { x: 0, y: 0, width: 1200, height: 800 };

test('cycles the default divisions and wraps to the first division', () => {
assert.deepEqual(DEFAULT_EDGE_DIVISIONS, [2, 3, 4]);
assert.equal(nextDivision(undefined), 2);
assert.equal(nextDivision(2), 3);
assert.equal(nextDivision(3), 4);
assert.equal(nextDivision(4), 2);
assert.equal(nextDivision(99), 2);
});

test('creates equal-width rectangles anchored to the left edge', () => {
assert.deepEqual(makeEdgeRect(workArea, 'left', 2), {
x: 0,
y: 0,
width: 600,
height: 800
});
assert.deepEqual(makeEdgeRect(workArea, 'left', 3), {
x: 0,
y: 0,
width: 400,
height: 800
});
assert.deepEqual(makeEdgeRect(workArea, 'left', 4), {
x: 0,
y: 0,
width: 300,
height: 800
});
});

test('creates equal-width rectangles anchored to the right edge', () => {
assert.deepEqual(makeEdgeRect(workArea, 'right', 2), {
x: 600,
y: 0,
width: 600,
height: 800
});
assert.deepEqual(makeEdgeRect(workArea, 'right', 3), {
x: 800,
y: 0,
width: 400,
height: 800
});
assert.deepEqual(makeEdgeRect(workArea, 'right', 4), {
x: 900,
y: 0,
width: 300,
height: 800
});
});

test('keeps the final pixel in the work area for an odd width', () => {
const oddWorkArea = { x: -1920, y: 10, width: 1919, height: 1080 };
const rects = [2, 3, 4].map(division => makeEdgeRect(oddWorkArea, 'left', division));

rects.forEach(rect => {
assert.equal(rect.x, oddWorkArea.x);
assert.equal(rect.y, oddWorkArea.y);
assert.equal(rect.height, oddWorkArea.height);
assert.ok(rect.width > 0);
assert.ok(rect.x + rect.width <= oddWorkArea.x + oddWorkArea.width);
});

const right = makeEdgeRect(oddWorkArea, 'right', 3);
assert.equal(right.x + right.width, oddWorkArea.x + oddWorkArea.width);
});

test('detects only full-height rectangles at the requested edge', () => {
const leftThird = makeEdgeRect(workArea, 'left', 3);
const rightQuarter = makeEdgeRect(workArea, 'right', 4);

assert.equal(isFullHeightEdgeRect(leftThird, workArea, 'left'), true);
assert.equal(isFullHeightEdgeRect(rightQuarter, workArea, 'right'), true);
assert.equal(detectEdgeDivision(leftThird, workArea, 'left'), 3);
assert.equal(detectEdgeDivision(rightQuarter, workArea, 'right'), 4);
assert.equal(detectEdgeDivision(leftThird, workArea, 'right'), null);
assert.equal(detectEdgeDivision({ ...leftThird, y: 10 }, workArea, 'left'), null);
assert.equal(detectEdgeDivision({ ...leftThird, height: 798 }, workArea, 'left'), null);
assert.equal(detectEdgeDivision({ ...leftThird, x: 20 }, workArea, 'left'), null);
});

test('allows a small coordinate tolerance when detecting a tile', () => {
const leftHalf = makeEdgeRect(workArea, 'left', 2);
const shifted = { ...leftHalf, x: 1, y: 1, width: 599, height: 799 };

assert.equal(detectEdgeDivision(shifted, workArea, 'left'), 2);
assert.equal(detectEdgeDivision(shifted, workArea, 'left', [2], 0), null);
});
3 changes: 2 additions & 1 deletion tiling-assistant@leleat-on-github/prefs.js
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,8 @@ export default class Prefs extends ExtensionPreferences {
'dynamic_keybinding_window_focus_row',
'dynamic_keybinding_tiling_state_row',
'dynamic_keybinding_tiling_state_windows_row',
'dynamic_keybinding_favorite_layout_row'
'dynamic_keybinding_favorite_layout_row',
'dynamic_keybinding_edge_cycle_row'
]
},
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
<default>false</default>
</key>
<key name="dynamic-keybinding-behavior" type="i">
<!-- 0: Disabled, 1: Focus, 2: Tiling State, 3: Tiling State (Windows), 4: Favorite Layout -->
<!-- 0: Disabled, 1: Focus, 2: Tiling State, 3: Tiling State (Windows), 4: Favorite Layout, 5: Edge Cycle -->
<default>0</default>
</key>
<key name="focus-hint" type="i">
Expand Down
1 change: 1 addition & 0 deletions tiling-assistant@leleat-on-github/src/common.js
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ export class DynamicKeybindings {
static TILING_STATE = 2;
static TILING_STATE_WINDOWS = 3;
static FAVORITE_LAYOUT = 4;
static EDGE_CYCLE = 5;
}

export const FocusHint = Object.freeze({
Expand Down
85 changes: 85 additions & 0 deletions tiling-assistant@leleat-on-github/src/extension/edgeCycle.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
export const DEFAULT_EDGE_DIVISIONS = Object.freeze([2, 3, 4]);

const SUPPORTED_SIDES = new Set(['left', 'right']);

function isClose(actual, expected, tolerance) {
return Math.abs(actual - expected) <= tolerance;
}

function isValidDivision(division) {
return Number.isInteger(division) && division > 0;
}

function validateDivisions(divisions) {
if (!divisions.length || divisions.some(division => !isValidDivision(division)))
throw new TypeError('divisions must contain positive integers');
}

function validateSide(side) {
if (!SUPPORTED_SIDES.has(side))
throw new TypeError(`Unsupported edge side: ${side}`);
}

export function nextDivision(currentDivision, divisions = DEFAULT_EDGE_DIVISIONS) {
validateDivisions(divisions);

const currentIndex = divisions.indexOf(currentDivision);
return divisions[(currentIndex + 1) % divisions.length];
}

export function makeEdgeRect(workArea, side, division) {
validateSide(side);
if (!isValidDivision(division))
throw new TypeError('division must be a positive integer');

const width = Math.floor(workArea.width / division);
const x = side === 'left'
? workArea.x
: workArea.x + workArea.width - width;

return {
x,
y: workArea.y,
width,
height: workArea.height
};
}

export function isFullHeightEdgeRect(rect, workArea, side, tolerance = 1) {
validateSide(side);

if (!rect || !workArea || tolerance < 0)
return false;

const atLeftEdge = isClose(rect.x, workArea.x, tolerance);
const atRightEdge = isClose(
rect.x + rect.width,
workArea.x + workArea.width,
tolerance
);
const atTopEdge = isClose(rect.y, workArea.y, tolerance);
const fullHeight = isClose(rect.height, workArea.height, tolerance);

return atTopEdge && fullHeight && (side === 'left' ? atLeftEdge : atRightEdge);
}

export function detectEdgeDivision(
rect,
workArea,
side,
divisions = DEFAULT_EDGE_DIVISIONS,
tolerance = 1
) {
validateDivisions(divisions);

if (!isFullHeightEdgeRect(rect, workArea, side, tolerance))
return null;

return divisions.find(division => {
const expected = makeEdgeRect(workArea, side, division);
return isClose(rect.x, expected.x, tolerance) &&
isClose(rect.y, expected.y, tolerance) &&
isClose(rect.width, expected.width, tolerance) &&
isClose(rect.height, expected.height, tolerance);
}) ?? null;
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ import { Clutter, Meta, Shell, St } from '../dependencies/gi.js';
import { _, Main } from '../dependencies/shell.js';

import { Direction, DynamicKeybindings, Settings, Shortcuts } from '../common.js';
import {
detectEdgeDivision,
makeEdgeRect,
nextDivision
} from './edgeCycle.js';
import { Rect, Util } from './utility.js';
import { TilingWindowManager as Twm } from './tilingWindowManager.js';

Expand Down Expand Up @@ -219,6 +224,13 @@ export default class TilingKeybindingHandler {
const windowsStyle = DynamicKeybindings.TILING_STATE_WINDOWS;
const isWindowsStyle = dynamicSetting === windowsStyle;
const workArea = new Rect(window.get_work_area_current_monitor());

if (dynamicSetting === DynamicKeybindings.EDGE_CYCLE &&
['tile-left-half', 'tile-right-half'].includes(shortcutName)) {
this._dynamicEdgeCycle(window, shortcutName, workArea);
return;
}

const rect = Twm.getTileFor(shortcutName, workArea, window.get_monitor());

switch (dynamicSetting) {
Expand All @@ -238,6 +250,25 @@ export default class TilingKeybindingHandler {
}
}

/**
* Cycles a window through equal-width full-height tiles at the selected
* screen edge.
*
* @param {Meta.Window} window a Meta.Window.
* @param {string} shortcutName the activated horizontal tile shortcut.
* @param {Rect} workArea the work area of the window's current monitor.
*/
_dynamicEdgeCycle(window, shortcutName, workArea) {
const side = shortcutName === 'tile-left-half' ? 'left' : 'right';
const currentRect = window.tiledRect ?? window.get_frame_rect();
const currentDivision = detectEdgeDivision(currentRect, workArea, side);
const division = nextDivision(currentDivision);
const edgeRect = makeEdgeRect(workArea, side, division);
const rect = new Rect(edgeRect.x, edgeRect.y, edgeRect.width, edgeRect.height);

Twm.tile(window, rect, { openTilingPopup: false });
}

/**
* Tiles or moves the focus depending on the `windows` tiling state.
*
Expand Down
12 changes: 12 additions & 0 deletions tiling-assistant@leleat-on-github/src/ui/prefs.ui
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,18 @@
</child>
</object>
</child>
<child>
<object class="AdwActionRow" id="dynamic_keybinding_edge_cycle_row">
<property name="title" translatable="yes" comments="Translators: This setting is under the 'Dynamic Keybinding Behavior' preference group. It cycles a window at the left or right edge through equal-width columns">Edge Cycle</property>
<property name="subtitle" translatable="yes" comments="Translators: This is an explanation for the 'Edge Cycle' option. Repeatedly pressing the same horizontal tile shortcut cycles through half, third, and quarter widths">Cycle a window at the left or right edge through half, third, and quarter widths</property>
<property name="activatable-widget">dynamic_keybinding_edge_cycle_button</property>
<child type="prefix">
<object class="GtkCheckButton" id="dynamic_keybinding_edge_cycle_button">
<property name="group">dynamic_keybinding_disabled_button</property>
</object>
</child>
</object>
</child>
</object>
</child>
<!-- ======================================================================================== -->
Expand Down
Loading
Loading