feat(tiles): Move Tile Left/Right/Up/Down (Ctrl+Shift+Arrows)

Four rebindable registry chords in the Tiles group move the focused
tile: it trades places with the neighbour the Alt+Shift+Arrow focus
chords pick (tileInDirection), through the same _reorderTiles path as
the header drag, and keeps the focus. Nothing at an edge.

They go through tileShortcutFor()/runTileShortcut() like the other
tile chords, so every xterm key handler swallows them while they
apply: only while the grid is open, a zoomed grid included (a no-op
there, so the keys never reach the CLI), and never in a text field
other than xterm's own textarea, where Ctrl+Shift+Arrows select by
word.

Ctrl+Shift+Arrows because every other two-modifier arrow chord is
taken: Ctrl+Alt switches workspaces (GNOME, Xfce, some Windows
graphics drivers), Ctrl+Alt+Shift moves a window to another workspace
(GNOME, Cinnamon, Xfce), Super belongs to the desktop, Alt is the
browser's back and forward, Alt+Shift focuses tiles. No browser,
GNOME, KDE, macOS or Claude Code default uses Ctrl+Shift+Arrows; it
costs a terminal editor's word selection inside a tile while the grid
is open.

The Help modal lists the chords and the header drag; the shortcut
overlay lists the registry. zh-CN entries for every new string.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-07 23:49:11 +02:00
parent 2b20288ca0
commit 20c2561a45
7 changed files with 328 additions and 6 deletions
+5 -1
View File
@@ -369,7 +369,7 @@ describe('every tile grid string the code puts on screen translates to zh-CN', (
it('the shortcut registry entries: the Tiles group, its labels, "not bound", the group menu item', () => {
const app = read('app.js');
const labels = [...app.matchAll(/group: 'Tiles',\s*label: '([^']+)'/g)].map((m) => m[1]);
expect(labels).toHaveLength(7);
expect(labels).toHaveLength(11);
expect(app).toContain("label: 'Open group as tiles'");
expect(app).toContain('not bound');
const bad = ['Tiles', 'not bound', 'Open group as tiles', ...labels].filter((s) => {
@@ -417,6 +417,10 @@ describe('the static markup through the real translator (JSDOM, zh-CN)', () => {
expect(leftover(tiles!.textContent!)).toEqual([]);
expect(tiles!.textContent).toContain('切换平铺网格');
expect(tiles!.textContent).toContain('右键单击');
// Moving tiles: the chords and the header drag.
expect(tiles!.textContent).toContain('向左 / 右 / 上 / 下移动窗格');
expect(tiles!.textContent).toContain('拖动');
expect(tiles!.textContent).toContain('窗格的标题栏');
});
it('user text stays as typed: a session name and a group name that are also UI words', () => {
+217
View File
@@ -17,6 +17,12 @@
* input starts no drag, and a double-click on the name still renames.
* - Moving is off while a tile is zoomed (the header drag, a tab drag of a
* tiled session, the chords) and with a single tile.
* - Move Tile Left/Right/Up/Down (Ctrl+Shift+Arrows): the focused tile trades
* places with the neighbour the Alt+Shift+Arrow focus chords pick, checked
* against hand-written tables for every cell and direction of 2x1, 2x2, 3x2
* and the partial 2x2 and 3x2; focus stays on the moved tile. The chords
* apply (and are swallowed) only while the grid is open, zoomed included as a
* no-op, and never in a text field, where Ctrl+Shift+Arrows select by word.
*
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
*/
@@ -24,6 +30,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
FakeEl,
FakeTile,
documentAddEventListener,
fetchSpy,
localStore,
makeGridApp,
@@ -405,3 +412,213 @@ describe('sizes belong to the cells: only a tile whose size changed fits, once',
expect(fitCounts(SIX)).toEqual({ 's-a': 1, 's-b': 1, 's-c': 0, 's-d': 0, 's-e': 0, 's-f': 0 });
});
});
describe('Move Tile Left/Right/Up/Down (Ctrl+Shift+Arrows)', () => {
const ARROW = { left: 'ArrowLeft', right: 'ArrowRight', up: 'ArrowUp', down: 'ArrowDown' } as const;
type Dir = keyof typeof ARROW;
const chord = (dir: Dir, overrides: Record<string, unknown> = {}) => ({
type: 'keydown',
key: ARROW[dir],
code: ARROW[dir],
ctrlKey: true,
shiftKey: true,
altKey: false,
metaKey: false,
preventDefault: vi.fn(),
target: { closest: () => null, classList: { contains: (c: string) => c === 'xterm-helper-textarea' } },
...overrides,
});
function handlerFor(app: GridApp) {
app.$ = () => null;
app.setupColorPicker = vi.fn();
const before = (documentAddEventListener.mock.calls as unknown[]).length;
app.setupEventListeners();
const added = (documentAddEventListener.mock.calls as Array<[string, (e: unknown) => void, boolean]>).slice(before);
const keydown = added.find(([type, , capture]) => type === 'keydown' && capture === true);
if (!keydown) throw new Error('no capture-phase keydown listener');
return keydown[1];
}
// The neighbour in each direction, by cell, written out by hand (null: an
// edge). A partial last row: down from a cell above an empty one goes to the
// last tile, the rule the focus chords follow.
const TABLES: Record<
string,
{ ids: string[]; cols: number; width?: number; next: Array<Record<Dir, number | null>> }
> = {
'2x1': {
cols: 2,
ids: ['s-a', 's-b'],
next: [
{ left: null, right: 1, up: null, down: null },
{ left: 0, right: null, up: null, down: null },
],
},
'2x2': {
cols: 2,
ids: FOUR,
next: [
{ left: null, right: 1, up: null, down: 2 },
{ left: 0, right: null, up: null, down: 3 },
{ left: null, right: 3, up: 0, down: null },
{ left: 2, right: null, up: 1, down: null },
],
},
'3x2': {
cols: 3,
ids: SIX,
next: [
{ left: null, right: 1, up: null, down: 3 },
{ left: 0, right: 2, up: null, down: 4 },
{ left: 1, right: null, up: null, down: 5 },
{ left: null, right: 4, up: 0, down: null },
{ left: 3, right: 5, up: 1, down: null },
{ left: 4, right: null, up: 2, down: null },
],
},
'2x2 with 3 tiles': {
cols: 2,
ids: ['s-a', 's-b', 's-c'],
width: 1700,
next: [
{ left: null, right: 1, up: null, down: 2 },
{ left: 0, right: null, up: null, down: 2 },
{ left: null, right: null, up: 0, down: null },
],
},
'3x2 with 5 tiles': {
cols: 3,
ids: SIX.slice(0, 5),
width: 1700,
next: [
{ left: null, right: 1, up: null, down: 3 },
{ left: 0, right: 2, up: null, down: 4 },
{ left: 1, right: null, up: null, down: 4 },
{ left: null, right: 4, up: 0, down: null },
{ left: 3, right: null, up: 1, down: null },
],
},
};
afterEach(() => {
delete (section as unknown as Record<string, unknown>).getBoundingClientRect;
});
for (const [layout, table] of Object.entries(TABLES)) {
it(`${layout}: every cell, every direction, swaps with the right neighbour (or nothing at an edge)`, () => {
const bad: string[] = [];
table.next.forEach((next, i) => {
for (const dir of Object.keys(ARROW) as Dir[]) {
resetGridHarness();
if (table.width) {
const w = table.width;
section.getBoundingClientRect = () => ({ width: w, height: 1000, top: 0, left: 0, right: w, bottom: 1000 });
}
const app = openGrid(table.ids, table.ids[i]);
expect(app._tileGrid.cols).toBe(table.cols);
const costsNothing = snapshotCost();
app.markIdleAlertSeen.mockClear();
const onKeydown = handlerFor(app);
const e = chord(dir);
onKeydown(e);
const expected = table.ids.slice();
const j = next[dir];
if (j !== null) [expected[i], expected[j]] = [expected[j], expected[i]];
const moved = table.ids[i];
const ok =
e.preventDefault.mock.calls.length === 1 &&
JSON.stringify(app._tileGrid.ids) === JSON.stringify(expected) &&
app.activeSessionId === moved &&
app._tileGrid.focusedId === moved &&
tileEl(moved).classList.contains('focused') &&
JSON.stringify(stored().ids) === JSON.stringify(expected) &&
stored().focused === moved;
if (!ok) bad.push(`cell ${i} ${dir}: got ${app._tileGrid.ids.join(',')} focus ${app.activeSessionId}`);
costsNothing();
// Equal cells: nothing changed size, so nothing fits.
expect(FakeTile.all.every((t) => t.fit.mock.calls.length === 0)).toBe(true);
}
});
expect(bad).toEqual([]);
});
}
it('focus stays on the moved tile through several moves, and the order is stored', () => {
const app = openGrid(SIX, 's-a');
const onKeydown = handlerFor(app);
for (const dir of ['right', 'right', 'down', 'left'] as Dir[]) onKeydown(chord(dir));
// a: 0 -> 1 -> 2 -> 5 -> 4
expect(app._tileGrid.ids.indexOf('s-a')).toBe(4);
expect(app.activeSessionId).toBe('s-a');
expect(stored().ids).toEqual(app._tileGrid.ids);
expect(stored().focused).toBe('s-a');
});
it('only a tile whose cell size changed fits, once', () => {
const app = openGrid(SIX, 's-a');
app._tileGrid.colFr = [2, 1, 1];
app._applyTileLayout();
clearFits();
handlerFor(app)(chord('right'));
expect(fitCounts(SIX)).toEqual({ 's-a': 1, 's-b': 1, 's-c': 0, 's-d': 0, 's-e': 0, 's-f': 0 });
});
describe('swallowed only while they apply', () => {
it('grid closed: not a tile chord, and the capture handler leaves it alone (it reaches the terminal)', () => {
const app = makeGridApp(FOUR);
// Even with the Tiles setting on (where the toggle chord would apply).
app.loadAppSettingsFromStorage = () => ({ showTileGridButton: true });
expect(app.tileShortcutFor(chord('right'))).toBeNull();
const e = chord('right');
handlerFor(app)(e);
expect(e.preventDefault).not.toHaveBeenCalled();
});
it('grid open, from a terminal: applies and is swallowed', () => {
const app = openGrid(FOUR);
expect(app.tileShortcutFor(chord('right'))).toBe('move-tile-right');
expect(app.tileShortcutFor(chord('down'))).toBe('move-tile-down');
});
it('in a text field (the rename input, an editor) it is left to the field: word selection', () => {
const app = openGrid(FOUR);
const onKeydown = handlerFor(app);
for (const tagName of ['INPUT', 'TEXTAREA']) {
const e = chord('right', { target: { tagName, closest: () => null, classList: { contains: () => false } } });
expect(app.tileShortcutFor(e)).toBeNull();
onKeydown(e);
expect(e.preventDefault).not.toHaveBeenCalled();
}
const editable = chord('left', { target: { isContentEditable: true, closest: () => null } });
expect(app.tileShortcutFor(editable)).toBeNull();
expect(app._tileGrid.ids).toEqual(FOUR);
});
it('while a tile is zoomed it still applies (swallowed, never typed into the CLI) and moves nothing', () => {
const app = openGrid(FOUR);
app.zoomTile('s-a');
const e = chord('right');
expect(app.tileShortcutFor(e)).toBe('move-tile-right');
handlerFor(app)(e);
expect(e.preventDefault).toHaveBeenCalled();
expect(app._tileGrid.ids).toEqual(FOUR);
expect(app._tileGrid.zoomedId).toBe('s-a');
});
it('honours a disable and a rebind from App Settings', () => {
const app = openGrid(FOUR);
app.loadAppSettingsFromStorage = () => ({
shortcutOverrides: {
'move-tile-right': { disabled: true },
'move-tile-left': { bindings: [{ modifiers: ['ctrl', 'alt'], key: 'h', code: 'KeyH' }] },
},
});
expect(app.tileShortcutFor(chord('right'))).toBeNull();
expect(app.tileShortcutFor(chord('left'))).toBeNull();
expect(app.tileShortcutFor(chord('left', { key: 'h', code: 'KeyH', shiftKey: false, altKey: true }))).toBe(
'move-tile-left'
);
});
});
});
+21 -2
View File
@@ -3,8 +3,8 @@
* that a chord which applies never reaches a PTY.
*
* Defaults (all rebindable in App Settings, Shortcuts): Ctrl+Shift+G toggles
* the grid, Alt+Shift+Arrows move focus between tiles, Remove Focused Tile is
* unbound. The toggle applies wherever a grid could open; the focus and remove
* the grid, Alt+Shift+Arrows move focus between tiles, Ctrl+Shift+Arrows move
* the focused tile (tile-grid-move.test.ts), Remove Focused Tile is unbound. The toggle applies wherever a grid could open; the focus and remove
* chords only while the grid is open, so outside it Alt+Shift+Arrows reach the
* terminal untouched. The capture-phase handler (app.js) dispatches a chord
* that applies; its preventDefault() does not stop xterm, so every xterm key
@@ -69,6 +69,11 @@ describe('registry', () => {
expect(byId[`focus-tile-${dir.toLowerCase()}`].bindings).toEqual([
{ modifiers: ['alt', 'shift'], key: `Arrow${dir}` },
]);
// Moving the focused tile (tile-grid-move.test.ts).
expect(byId[`move-tile-${dir.toLowerCase()}`].bindings).toEqual([
{ modifiers: ['ctrl', 'shift'], key: `Arrow${dir}` },
]);
expect(byId[`move-tile-${dir.toLowerCase()}`].group).toBe('Tiles');
}
expect(byId['remove-tile'].bindings).toEqual([]);
expect(byId['toggle-tile-grid'].group).toBe('Tiles');
@@ -351,6 +356,20 @@ describe('xterm key handlers swallow a chord that applies', () => {
expect(handler(chord(RIGHT))).toBe(true);
});
const MOVE_RIGHT = { key: 'ArrowRight', code: 'ArrowRight', ctrlKey: true, shiftKey: true };
it.each(['keydown', 'keypress', 'keyup'])(
'returns false for Ctrl+Shift+Right, Move Tile Right (%s), while the grid is open',
async (type) => {
const handler = await tileHandler(true);
expect(handler({ ...chord(MOVE_RIGHT), type })).toBe(false);
}
);
it('lets Ctrl+Shift+Right through to the PTY when no grid is open', async () => {
const handler = await tileHandler(false);
expect(handler(chord(MOVE_RIGHT))).toBe(true);
});
it('Alt+Shift+Enter (zoom) never becomes a Shift+Enter newline in the tile session', async () => {
const handler = await tileHandler(true);
fetchMock.mockClear();