From acf4c5c6ae8f9963008aa8336b0ce9fd96142f58 Mon Sep 17 00:00:00 2001 From: Peter Fajdiga Date: Sat, 5 Oct 2024 20:56:04 +0200 Subject: [PATCH] Actions: add actions `columnShrinkLeft` and `columnShrinkRight` --- src/lib/keyBindings/Actions.ts | 75 ++++++++++++++++++++++++ src/lib/keyBindings/definition.ts | 13 +++++ src/lib/utils/math.ts | 4 ++ src/tests/flows/columnShrinkSide.ts | 77 +++++++++++++++++++++++++ src/tests/utils/Assert.ts | 41 +++++++++++++ src/tests/utils/global.ts | 4 ++ src/tests/utils/mocks/MockKwinClient.ts | 2 +- 7 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 src/tests/flows/columnShrinkSide.ts diff --git a/src/lib/keyBindings/Actions.ts b/src/lib/keyBindings/Actions.ts index 7d3cb77..ccfcf1a 100644 --- a/src/lib/keyBindings/Actions.ts +++ b/src/lib/keyBindings/Actions.ts @@ -184,6 +184,81 @@ class Actions { this.config.columnResizer.decreaseWidth(column, this.config.manualResizeStep); } + public readonly columnShrinkLeft = (cm: ClientManager, dm: DesktopManager, window: Window, focusedColumn: Column, grid: Grid) => { + const visibleRange = grid.desktop.getCurrentVisibleRange(); + if (!focusedColumn.isVisible(visibleRange, true)) { + return; + } + + const visibleColumns: Column[] = []; + for (const visibleColumn of grid.getVisibleColumns(visibleRange, true)) { + visibleColumns.push(visibleColumn); + if (visibleColumn === focusedColumn) { + break; + } + } + console.assert(visibleColumns.length > 0, "should at least contain the focused column"); + + const targetColumn = grid.getLeftColumn(visibleColumns[0]); + if (targetColumn === null) { + return; + } + + this.squeezeColumns([targetColumn, ...visibleColumns]); + } + + public readonly columnShrinkRight = (cm: ClientManager, dm: DesktopManager, window: Window, focusedColumn: Column, grid: Grid) => { + const visibleRange = grid.desktop.getCurrentVisibleRange(); + if (!focusedColumn.isVisible(visibleRange, true)) { + return; + } + + const visibleColumns: Column[] = [focusedColumn]; + for (const visibleColumn of grid.getVisibleColumns(visibleRange, true)) { + if (visibleColumn.isToTheRightOf(focusedColumn)) { + visibleColumns.push(visibleColumn); + } + } + + const targetColumn = grid.getRightColumn(visibleColumns[visibleColumns.length-1]); + if (targetColumn === null) { + return; + } + + this.squeezeColumns([...visibleColumns, targetColumn]); + } + + private readonly squeezeColumns = (columns: Column[]) => { + const firstColumn = columns[0]; + const lastColumn = columns[columns.length-1]; + const desktop = firstColumn.grid.desktop; + + const neededSpace = lastColumn.getRight() - firstColumn.getLeft(); + const availableSpace = desktop.tilingArea.width; + let missingSpace = neededSpace - availableSpace; + if (missingSpace <= 0) { + // just scroll + desktop.scrollCenterRange(Desktop.RangeImpl.fromRanges(firstColumn, lastColumn)); + return; + } + + const gainableSpacePerColumn = columns.map(column => column.getWidth() - column.getMinWidth()); + const gainableSpaceTotal = sum(...gainableSpacePerColumn); + if (gainableSpaceTotal < missingSpace) { + // there's nothing we can do + return; + } + + const shrinkRatio = missingSpace / gainableSpaceTotal; + for (let i = 0; i < columns.length-1; i++) { + const shrinkAmount = Math.round(gainableSpacePerColumn[i] * shrinkRatio); + columns[i].adjustWidth(-shrinkAmount, true); + missingSpace -= shrinkAmount; + } + lastColumn.adjustWidth(-missingSpace, true); + desktop.scrollCenterRange(Desktop.RangeImpl.fromRanges(firstColumn, lastColumn)); + } + public readonly cyclePresetWidths = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => { if (this.config.presetWidths === null) { return; diff --git a/src/lib/keyBindings/definition.ts b/src/lib/keyBindings/definition.ts index 828b43b..df4cb2a 100644 --- a/src/lib/keyBindings/definition.ts +++ b/src/lib/keyBindings/definition.ts @@ -146,6 +146,19 @@ function getKeyBindings(world: World, actions: Actions): KeyBinding[] { defaultKeySequence: "Meta+Ctrl+-", action: () => world.doIfTiledFocused(actions.columnWidthDecrease), }, + { + name: "column-shrink-left", + description: "Decrease column width to make room for the left column", + comment: "Clashes with default KDE shortcuts, may require manual remapping", + defaultKeySequence: "Meta+Ctrl+A", + action: () => world.doIfTiledFocused(actions.columnShrinkLeft), + }, + { + name: "column-shrink-right", + description: "Decrease column width to make room for the right column", + defaultKeySequence: "Meta+Ctrl+D", + action: () => world.doIfTiledFocused(actions.columnShrinkRight), + }, { name: "cycle-preset-widths", description: "Cycle through preset column widths", diff --git a/src/lib/utils/math.ts b/src/lib/utils/math.ts index f56b37b..9c1bd4f 100644 --- a/src/lib/utils/math.ts +++ b/src/lib/utils/math.ts @@ -8,6 +8,10 @@ function clamp(value: number, min: number, max: number) { return value; } +function sum(...list: number[]) { + return list.reduce((acc, val) => acc + val); +} + function union(array0: T[], array1: T[]) { const set = new Set([...array0, ...array1]); return [...set]; diff --git a/src/tests/flows/columnShrinkSide.ts b/src/tests/flows/columnShrinkSide.ts new file mode 100644 index 0000000..74ecbbb --- /dev/null +++ b/src/tests/flows/columnShrinkSide.ts @@ -0,0 +1,77 @@ +tests.register("column shrink left", 1, () => { + const baseTestCases = [ + { widths: [500, 500], blocked: [false, false], possible: true }, + { widths: [500, 768], blocked: [false, false], possible: true }, + { widths: [500, 500], blocked: [false, true], possible: true }, + { widths: [500, 200, 200], blocked: [false, false, false], possible: true }, + { widths: [500, 200, 200], blocked: [false, false, true], possible: true }, + { widths: [500, 200, 200], blocked: [true, false, true], possible: true }, + { widths: [500, 500, 500], blocked: [false, true, true], possible: false }, + ]; + + const testCasesLeft = baseTestCases.map((baseTestCase, i) => ({ + ...baseTestCase, + name: "left " + i, + action: "karousel-column-shrink-left", + focus: baseTestCase.widths.length-1, + })); + + const testCasesRight = baseTestCases.map((baseTestCase, i) => ({ + ...baseTestCase, + widths: baseTestCase.widths.slice().reverse(), + blocked: baseTestCase.blocked.slice().reverse(), + name: "right " + i, + action: "karousel-column-shrink-right", + focus: 0, + })); + + const testCases = [...testCasesLeft, ...testCasesRight]; + + for (const testCase of testCases) { + const assertOpt = { message: `Case: ${testCase.name}` }; + + const config = getDefaultConfig(); + const { qtMock, workspaceMock, world } = init(config); + + const clients = workspaceMock.createClientsWithWidths(...testCase.widths); + workspaceMock.activeWindow = clients[testCase.focus]; + for (let i = 0; i < clients.length; i++) { + if (testCase.blocked[i]) { + clients[i].minSize = new MockQmlSize(testCase.widths[i], 100); + } + } + + if (testCase.possible) { + qtMock.fireShortcut(testCase.action); + Assert.columnsFillTilingArea(clients, assertOpt); + for (let i = 0; i < clients.length; i++) { + if (testCase.blocked[i]) { + Assert.equal(clients[i].frameGeometry.width, testCase.widths[i], assertOpt); + } + } + } + + const frames = clients.map(client => client.frameGeometry); + qtMock.fireShortcut(testCase.action); + const newFrames = clients.map(client => client.frameGeometry); + for (let i = 0; i < clients.length; i++) { + Assert.equalRects(frames[i], newFrames[i], assertOpt); + } + } +}); + +tests.register("column shrink left (just scroll)", 1, () => { + const config = getDefaultConfig(); + const { qtMock, workspaceMock, world } = init(config); + + const [ clientLeft, clientMiddle, clientRight ] = workspaceMock.createClientsWithWidths(300, 300, 300); + workspaceMock.activeWindow = clientMiddle; + Assert.notFullyVisible(clientLeft.frameGeometry); + Assert.fullyVisible(clientMiddle.frameGeometry); + Assert.fullyVisible(clientRight.frameGeometry); + + qtMock.fireShortcut("karousel-column-shrink-left"); + Assert.fullyVisible(clientLeft.frameGeometry); + Assert.fullyVisible(clientMiddle.frameGeometry); + Assert.notFullyVisible(clientRight.frameGeometry); +}); diff --git a/src/tests/utils/Assert.ts b/src/tests/utils/Assert.ts index 10650ec..520985e 100644 --- a/src/tests/utils/Assert.ts +++ b/src/tests/utils/Assert.ts @@ -140,4 +140,45 @@ namespace Assert { } } } + + export function fullyVisible( + rect: QmlRect, + { message, skip=0 }: Options = {}, + ) { + assert( + rect.left >= tilingArea.left && rect.right <= tilingArea.right, + { + message: appendMessage(`Rect ${rect} not fully visible`, message), + skip: skip + 1, + }, + ); + } + + export function notFullyVisible( + rect: QmlRect, + { message, skip=0 }: Options = {}, + ) { + assert( + rect.left < tilingArea.left || rect.right > tilingArea.right, + { + message: appendMessage(`Rect ${rect} is fully visible, but shouldn't be`, message), + skip: skip + 1, + }, + ); + } + + export function columnsFillTilingArea( + columns: KwinClient[], + { message, skip=0 }: Options = {}, + ) { + const options = { message: message, skip: skip+1 }; + let x = tilingArea.left; + for (const column of columns) { + const width = column.frameGeometry.width; + fullyVisible(column.frameGeometry, options); + rect(column.frameGeometry, x, tilingArea.top, width, tilingArea.height, options); + x += width + gapH; + } + equal(columns[columns.length-1].frameGeometry.right, tilingArea.right, options); + } } diff --git a/src/tests/utils/global.ts b/src/tests/utils/global.ts index d6eed05..e0c9e24 100644 --- a/src/tests/utils/global.ts +++ b/src/tests/utils/global.ts @@ -7,6 +7,8 @@ let notificationInvalidPresetWidths: Notification; let screen: QmlRect; let tilingArea: QmlRect; +let gapH: number; +let gapV: number; let runLog: string[]; function init(config: Config) { @@ -17,6 +19,8 @@ function init(config: Config) { screen.width - config.gapsOuterLeft - config.gapsOuterRight, screen.height - config.gapsOuterTop - config.gapsOuterBottom, ); + gapH = config.gapsInnerHorizontal; + gapV = config.gapsInnerVertical; runLog = []; const qtMock = new MockQt(); diff --git a/src/tests/utils/mocks/MockKwinClient.ts b/src/tests/utils/mocks/MockKwinClient.ts index d7ff695..d6864e7 100644 --- a/src/tests/utils/mocks/MockKwinClient.ts +++ b/src/tests/utils/mocks/MockKwinClient.ts @@ -5,7 +5,7 @@ class MockKwinClient { public readonly shadeable: boolean = false; public readonly caption = "App"; - public readonly minSize: Readonly = new MockQmlSize(0, 0); + public minSize: Readonly = new MockQmlSize(0, 0); public readonly transient: boolean; public readonly move: boolean = false; public readonly resize: boolean = false;