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:
@@ -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',
|
||||||
|
|||||||
@@ -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': '将该会话加入平铺网格',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user