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
+39
View File
@@ -579,6 +579,45 @@ const DEFAULT_SHORTCUTS = [
bindings: [{ modifiers: ['alt', 'shift'], key: 'ArrowDown' }], bindings: [{ modifiers: ['alt', 'shift'], key: 'ArrowDown' }],
action: 'focusTileDown', action: 'focusTileDown',
}, },
// Move the focused tile: it trades places with the neighbour the focus
// chords would pick. Ctrl+Shift+Arrows because every other two-modifier
// arrow chord is taken: Ctrl+Alt+Arrows switch workspaces (GNOME, Xfce, and
// rotate the screen on some Windows graphics drivers), Ctrl+Alt+Shift+Arrows
// move a window to another workspace (GNOME, Cinnamon, Xfce), Super chords
// belong to the desktop, Alt+Arrows are the browser's back and forward, and
// Alt+Shift+Arrows focus tiles. No browser, GNOME, KDE or macOS default and
// no Claude Code default uses Ctrl+Shift+Arrows (it parallels Ctrl+Shift+{ }
// for moving tabs); what it costs is word selection, so the chords skip a
// text field (tileShortcutFor) and give up only a terminal editor's
// word selection (nano, micro, emacs) inside a tile while the grid is open.
{
id: 'move-tile-left',
group: 'Tiles',
label: 'Move Tile Left',
bindings: [{ modifiers: ['ctrl', 'shift'], key: 'ArrowLeft' }],
action: 'moveTileLeft',
},
{
id: 'move-tile-right',
group: 'Tiles',
label: 'Move Tile Right',
bindings: [{ modifiers: ['ctrl', 'shift'], key: 'ArrowRight' }],
action: 'moveTileRight',
},
{
id: 'move-tile-up',
group: 'Tiles',
label: 'Move Tile Up',
bindings: [{ modifiers: ['ctrl', 'shift'], key: 'ArrowUp' }],
action: 'moveTileUp',
},
{
id: 'move-tile-down',
group: 'Tiles',
label: 'Move Tile Down',
bindings: [{ modifiers: ['ctrl', 'shift'], key: 'ArrowDown' }],
action: 'moveTileDown',
},
{ {
id: 'zoom-tile', id: 'zoom-tile',
group: 'Tiles', group: 'Tiles',
+8
View File
@@ -114,6 +114,14 @@
'Focus Tile Up': '聚焦上方窗格', 'Focus Tile Up': '聚焦上方窗格',
'Focus Tile Down': '聚焦下方窗格', 'Focus Tile Down': '聚焦下方窗格',
'Focus Tile Left / Right / Up / Down': '聚焦左侧 / 右侧 / 上方 / 下方窗格', 'Focus Tile Left / Right / Up / Down': '聚焦左侧 / 右侧 / 上方 / 下方窗格',
'Move Tile Left': '向左移动窗格',
'Move Tile Right': '向右移动窗格',
'Move Tile Up': '向上移动窗格',
'Move Tile Down': '向下移动窗格',
'Move Tile Left / Right / Up / Down': '向左 / 右 / 上 / 下移动窗格',
Drag: '拖动',
"a tile's header": '窗格的标题栏',
'Move the Tile (onto Another: Swap)': '移动窗格(拖到另一个窗格上:互换位置)',
'Zoom Focused Tile': '放大聚焦的窗格', 'Zoom Focused Tile': '放大聚焦的窗格',
'Remove Focused Tile': '移除聚焦的窗格', 'Remove Focused Tile': '移除聚焦的窗格',
'Add the Session to the Tile Grid': '将该会话加入平铺网格', 'Add the Session to the Tile Grid': '将该会话加入平铺网格',
+2
View File
@@ -838,6 +838,8 @@
<div class="shortcuts-grid"> <div class="shortcuts-grid">
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>G</kbd></div><div>Toggle Tile Grid</div> <div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>G</kbd></div><div>Toggle Tile Grid</div>
<div><kbd>Alt/Option</kbd>+<kbd>Shift</kbd>+<kbd>Arrows</kbd></div><div>Focus Tile Left / Right / Up / Down</div> <div><kbd>Alt/Option</kbd>+<kbd>Shift</kbd>+<kbd>Arrows</kbd></div><div>Focus Tile Left / Right / Up / Down</div>
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>Arrows</kbd></div><div>Move Tile Left / Right / Up / Down</div>
<div><kbd>Drag</kbd> a tile's header</div><div>Move the Tile (onto Another: Swap)</div>
<div><kbd>Alt/Option</kbd>+<kbd>Shift</kbd>+<kbd>Enter</kbd></div><div>Zoom Focused Tile</div> <div><kbd>Alt/Option</kbd>+<kbd>Shift</kbd>+<kbd>Enter</kbd></div><div>Zoom Focused Tile</div>
<div><kbd>Ctrl/Cmd</kbd>+<kbd>Click</kbd> a tab</div><div>Add the Session to the Tile Grid</div> <div><kbd>Ctrl/Cmd</kbd>+<kbd>Click</kbd> a tab</div><div>Add the Session to the Tile Grid</div>
<div><kbd>Right-click</kbd> the Tiles button</div><div>Choose Which Sessions to Tile</div> <div><kbd>Right-click</kbd> the Tiles button</div><div>Choose Which Sessions to Tile</div>
+36 -3
View File
@@ -40,17 +40,33 @@ function tileGridTracks(fr) {
return fr.map((f) => `minmax(0, ${Math.round(f * 1000) / 1000}fr)`).join(` ${TILE_DIVIDER_PX}px `); return fr.map((f) => `minmax(0, ${Math.round(f * 1000) / 1000}fr)`).join(` ${TILE_DIVIDER_PX}px `);
} }
// Registry ids of the tile chords (DEFAULT_SHORTCUTS, app.js), and whether each // Registry ids of the tile chords (DEFAULT_SHORTCUTS, app.js), and whether each
// needs the grid open. The toggle applies wherever a grid could open. // needs the grid open. The toggle applies wherever a grid could open. The move
// chords leave a text field its keys (`move`: see tileShortcutFor).
const TILE_SHORTCUTS = { const TILE_SHORTCUTS = {
'toggle-tile-grid': { needsOpen: false }, 'toggle-tile-grid': { needsOpen: false },
'focus-tile-left': { needsOpen: true, direction: 'left' }, 'focus-tile-left': { needsOpen: true, direction: 'left' },
'focus-tile-right': { needsOpen: true, direction: 'right' }, 'focus-tile-right': { needsOpen: true, direction: 'right' },
'focus-tile-up': { needsOpen: true, direction: 'up' }, 'focus-tile-up': { needsOpen: true, direction: 'up' },
'focus-tile-down': { needsOpen: true, direction: 'down' }, 'focus-tile-down': { needsOpen: true, direction: 'down' },
'move-tile-left': { needsOpen: true, move: 'left' },
'move-tile-right': { needsOpen: true, move: 'right' },
'move-tile-up': { needsOpen: true, move: 'up' },
'move-tile-down': { needsOpen: true, move: 'down' },
'remove-tile': { needsOpen: true }, 'remove-tile': { needsOpen: true },
'zoom-tile': { needsOpen: true }, 'zoom-tile': { needsOpen: true },
}; };
/**
* A text field other than a terminal's own input (xterm's helper textarea):
* the rename input, the file editor, a settings field. Ctrl+Shift+Arrows
* selects by word there, so the move chords leave it alone.
*/
function isTextFieldTarget(target) {
if (!target || target.classList?.contains?.('xterm-helper-textarea')) return false;
const tag = String(target.tagName || '').toUpperCase();
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target.isContentEditable === true;
}
/** The grid's state. `has(id)` answers only while it is open. */ /** The grid's state. `has(id)` answers only while it is open. */
class TileGridModel { class TileGridModel {
constructor() { constructor() {
@@ -528,9 +544,11 @@ Object.assign(CodemanApp.prototype, {
* toggle applies while the grid is open, or where one could open AND the * toggle applies while the grid is open, or where one could open AND the
* per-device `showTileGridButton` setting is on: with it off (the default) * per-device `showTileGridButton` setting is on: with it off (the default)
* the chord is inert and reaches the terminal like any unbound key (owner * the chord is inert and reaches the terminal like any unbound key (owner
* decision 6 in docs/tile-grid-plan.md). The focus, * decision 6 in docs/tile-grid-plan.md). The focus, move,
* zoom and remove chords apply only while the grid is open, however it was * zoom and remove chords apply only while the grid is open, however it was
* opened. Registry-aware (rebinds and disables in App Settings, Shortcuts). * opened (a move chord also while a tile is zoomed, as a no-op, so its keys
* never reach the CLI; and never in a text field, whose keys they are).
* Registry-aware (rebinds and disables in App Settings, Shortcuts).
* The capture handler (app.js) dispatches it; every xterm key handler returns * The capture handler (app.js) dispatches it; every xterm key handler returns
* false for it, so a chord that applies never reaches a PTY. * false for it, so a chord that applies never reaches a PTY.
* *
@@ -543,6 +561,7 @@ Object.assign(CodemanApp.prototype, {
for (const shortcut of this.getShortcutRegistry()) { for (const shortcut of this.getShortcutRegistry()) {
const spec = TILE_SHORTCUTS[shortcut.id]; const spec = TILE_SHORTCUTS[shortcut.id];
if (!spec || shortcut.disabled || !this.matchesShortcutEvent(e, shortcut)) continue; if (!spec || shortcut.disabled || !this.matchesShortcutEvent(e, shortcut)) continue;
if (spec.move && isTextFieldTarget(e.target)) continue;
if (spec.needsOpen) return open ? shortcut.id : null; if (spec.needsOpen) return open ? shortcut.id : null;
const enabled = this.loadAppSettingsFromStorage?.()?.showTileGridButton === true; const enabled = this.loadAppSettingsFromStorage?.()?.showTileGridButton === true;
return open || (enabled && this.canOpenTileGrid()) ? shortcut.id : null; return open || (enabled && this.canOpenTileGrid()) ? shortcut.id : null;
@@ -558,6 +577,7 @@ Object.assign(CodemanApp.prototype, {
else if (id === 'remove-tile') this.removeFocusedTile(); else if (id === 'remove-tile') this.removeFocusedTile();
else if (id === 'zoom-tile') this.zoomTile(this._tileGrid?.focusedId); else if (id === 'zoom-tile') this.zoomTile(this._tileGrid?.focusedId);
else if (spec.direction) this.focusTileInDirection(spec.direction); else if (spec.direction) this.focusTileInDirection(spec.direction);
else if (spec.move) this.moveTileInDirection(spec.move);
}, },
/** /**
@@ -838,6 +858,19 @@ Object.assign(CodemanApp.prototype, {
return this._reorderTiles(ids); return this._reorderTiles(ids);
}, },
/**
* Move Tile Left/Right/Up/Down: the focused tile trades places with its
* neighbour in that direction, the neighbour the Alt+Shift+Arrow focus
* chords pick (tileInDirection). Focus stays on the moved tile. Nothing while
* a tile is zoomed, or at an edge.
*/
moveTileInDirection(direction) {
const grid = this._tileGrid;
if (!grid?.open || grid.zoomedId || !grid.focusedId) return;
const neighbor = window.CodemanTileGrid.tileInDirection(grid.ids, grid.focusedId, direction, grid.cols);
if (neighbor) this._swapTiles(grid.focusedId, neighbor);
},
/** /**
* A tab dropped on a tile, or a tile dragged there by its header: a session * A tab dropped on a tile, or a tile dragged there by its header: a session
* not yet tiled REPLACES that tile (same place; the replaced session keeps * not yet tiled REPLACES that tile (same place; the replaced session keeps
+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', () => { it('the shortcut registry entries: the Tiles group, its labels, "not bound", the group menu item', () => {
const app = read('app.js'); const app = read('app.js');
const labels = [...app.matchAll(/group: 'Tiles',\s*label: '([^']+)'/g)].map((m) => m[1]); 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("label: 'Open group as tiles'");
expect(app).toContain('not bound'); expect(app).toContain('not bound');
const bad = ['Tiles', 'not bound', 'Open group as tiles', ...labels].filter((s) => { 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(leftover(tiles!.textContent!)).toEqual([]);
expect(tiles!.textContent).toContain('切换平铺网格'); expect(tiles!.textContent).toContain('切换平铺网格');
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', () => { 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. * 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 * - 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. * 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. * 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 { import {
FakeEl, FakeEl,
FakeTile, FakeTile,
documentAddEventListener,
fetchSpy, fetchSpy,
localStore, localStore,
makeGridApp, 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 }); 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. * that a chord which applies never reaches a PTY.
* *
* Defaults (all rebindable in App Settings, Shortcuts): Ctrl+Shift+G toggles * Defaults (all rebindable in App Settings, Shortcuts): Ctrl+Shift+G toggles
* the grid, Alt+Shift+Arrows move focus between tiles, Remove Focused Tile is * the grid, Alt+Shift+Arrows move focus between tiles, Ctrl+Shift+Arrows move
* unbound. The toggle applies wherever a grid could open; the focus and remove * 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 * 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 * terminal untouched. The capture-phase handler (app.js) dispatches a chord
* that applies; its preventDefault() does not stop xterm, so every xterm key * 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([ expect(byId[`focus-tile-${dir.toLowerCase()}`].bindings).toEqual([
{ modifiers: ['alt', 'shift'], key: `Arrow${dir}` }, { 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['remove-tile'].bindings).toEqual([]);
expect(byId['toggle-tile-grid'].group).toBe('Tiles'); 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); 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 () => { it('Alt+Shift+Enter (zoom) never becomes a Shift+Enter newline in the tile session', async () => {
const handler = await tileHandler(true); const handler = await tileHandler(true);
fetchMock.mockClear(); fetchMock.mockClear();