mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 08:59:40 +02:00
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:
@@ -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', () => {
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user