Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions src/vs/platform/native/common/native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,38 @@ export interface INativeHostOptions {
readonly targetWindowId?: number;
}

/**
* The amount to grow or shrink a window by in pixels.
*/
export interface IWindowResizeDelta {
readonly width: number;
readonly height: number;
}

/**
* The edges to keep fixed in place while resizing a window. When `right` is
* `true`, the window grows/shrinks to the left, otherwise the left edge stays
* fixed. When `bottom` is `true`, the window grows/shrinks upwards, otherwise
* the top edge stays fixed.
*/
export interface IWindowResizeAnchor {
readonly right: boolean;
readonly bottom: boolean;
}

/**
* Computes new window bounds by applying the `delta` to `bounds` while keeping
* the edges indicated by `anchor` fixed in place.
*/
export function getResizedWindowBounds(bounds: IRectangle, delta: IWindowResizeDelta, anchor: IWindowResizeAnchor): IRectangle {
const width = bounds.width + delta.width;
const height = bounds.height + delta.height;
const x = anchor.right ? bounds.x + bounds.width - width : bounds.x;
const y = anchor.bottom ? bounds.y + bounds.height - height : bounds.y;

return { x, y, width, height };
}

export interface IStartTracingOptions {

/**
Expand Down Expand Up @@ -153,6 +185,17 @@ export interface ICommonNativeHostService {
moveWindowTop(options?: INativeHostOptions): Promise<void>;
positionWindow(position: IRectangle, options?: INativeHostOptions): Promise<void>;

/**
* Resizes the window by the delta, keeping the edges as indicated by
* the anchor fixed in place. Has no effect when the window is maximized or in
* full screen.
*
* @param delta The amount to grow or shrink the window in pixels.
* @param anchor The edges to keep fixed while resizing.
* @param options Options to target a specific window.
*/
resizeWindow(delta: IWindowResizeDelta, anchor: IWindowResizeAnchor, options?: INativeHostOptions): Promise<void>;

isWindowAlwaysOnTop(options?: INativeHostOptions): Promise<boolean>;
toggleWindowAlwaysOnTop(options?: INativeHostOptions): Promise<void>;
setWindowAlwaysOnTop(alwaysOnTop: boolean, options?: INativeHostOptions): Promise<void>;
Expand Down
20 changes: 19 additions & 1 deletion src/vs/platform/native/electron-main/nativeHostMainService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import { IEnvironmentMainService } from '../../environment/electron-main/environ
import { createDecorator, IInstantiationService } from '../../instantiation/common/instantiation.js';
import { ILifecycleMainService, IRelaunchOptions } from '../../lifecycle/electron-main/lifecycleMainService.js';
import { ILogService } from '../../log/common/log.js';
import { FocusMode, ICommonNativeHostService, INativeHostOptions, IOSProperties, IOSStatistics, IStartTracingOptions, IToastOptions, IToastResult, PowerSaveBlockerType, SystemIdleState, ThermalState } from '../common/native.js';
import { FocusMode, ICommonNativeHostService, INativeHostOptions, IOSProperties, IOSStatistics, IStartTracingOptions, IToastOptions, IToastResult, IWindowResizeAnchor, IWindowResizeDelta, getResizedWindowBounds, PowerSaveBlockerType, SystemIdleState, ThermalState } from '../common/native.js';
import { IProductService } from '../../product/common/productService.js';
import { IPartsSplash } from '../../theme/common/themeService.js';
import { IThemeMainService } from '../../theme/electron-main/themeMainService.js';
Expand Down Expand Up @@ -385,6 +385,24 @@ export class NativeHostMainService extends Disposable implements INativeHostMain
}
}

async resizeWindow(windowId: number | undefined, delta: IWindowResizeDelta, anchor: IWindowResizeAnchor, options?: INativeHostOptions): Promise<void> {
const window = this.windowById(options?.targetWindowId, windowId);
if (!window?.win || window.win.isFullScreen() || window.win.isMaximized()) {
return;
}

const currentBounds = window.win.getBounds();
const [minWidth, minHeight] = window.win.getMinimumSize();

const desiredBounds = getResizedWindowBounds(currentBounds, delta, anchor);
desiredBounds.width = Math.max(desiredBounds.width, minWidth);
desiredBounds.height = Math.max(desiredBounds.height, minHeight);
desiredBounds.x = anchor.right ? currentBounds.x + currentBounds.width - desiredBounds.width : currentBounds.x;
desiredBounds.y = anchor.bottom ? currentBounds.y + currentBounds.height - desiredBounds.height : currentBounds.y;

window.win.setBounds(desiredBounds);
}

async updateWindowControls(windowId: number | undefined, options: INativeHostOptions & { height?: number; backgroundColor?: string; foregroundColor?: string; dimmed?: boolean }): Promise<void> {
const window = this.windowById(options?.targetWindowId, windowId);
window?.updateWindowControls(options);
Expand Down
57 changes: 57 additions & 0 deletions src/vs/platform/native/test/common/native.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import assert from 'assert';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
import { getResizedWindowBounds } from '../../common/native.js';

suite('getResizedWindowBounds', () => {

ensureNoDisposablesAreLeakedInTestSuite();

const bounds = { x: 100, y: 200, width: 800, height: 600 };

test('left/top anchor keeps the origin fixed when growing', () => {
assert.deepStrictEqual(
getResizedWindowBounds(bounds, { width: 50, height: 30 }, { right: false, bottom: false }),
{ x: 100, y: 200, width: 850, height: 630 }
);
});

test('right anchor keeps the right edge fixed when growing', () => {
assert.deepStrictEqual(
getResizedWindowBounds(bounds, { width: 50, height: 0 }, { right: true, bottom: false }),
{ x: 50, y: 200, width: 850, height: 600 }
);
});

test('bottom anchor keeps the bottom edge fixed when growing', () => {
assert.deepStrictEqual(
getResizedWindowBounds(bounds, { width: 0, height: 30 }, { right: false, bottom: true }),
{ x: 100, y: 170, width: 800, height: 630 }
);
});

test('right and bottom anchor keeps the bottom-right corner fixed when growing', () => {
assert.deepStrictEqual(
getResizedWindowBounds(bounds, { width: 50, height: 30 }, { right: true, bottom: true }),
{ x: 50, y: 170, width: 850, height: 630 }
);
});

test('negative delta shrinks the window toward the anchored edge', () => {
assert.deepStrictEqual(
getResizedWindowBounds(bounds, { width: -50, height: -30 }, { right: true, bottom: true }),
{ x: 150, y: 230, width: 750, height: 570 }
);
});

test('zero delta leaves the bounds unchanged', () => {
assert.deepStrictEqual(
getResizedWindowBounds(bounds, { width: 0, height: 0 }, { right: true, bottom: true }),
{ x: 100, y: 200, width: 800, height: 600 }
);
});
});
127 changes: 121 additions & 6 deletions src/vs/workbench/browser/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable }
import { Event, Emitter } from '../../base/common/event.js';
import { EventType, addDisposableListener, getClientArea, size, IDimension, isAncestorUsingFlowTo, computeScreenAwareSize, getActiveDocument, getWindows, getActiveWindow, isActiveDocument, getWindow, getWindowId, getActiveElement, Dimension } from '../../base/browser/dom.js';
import { onDidChangeFullscreen, isFullscreen, isWCOEnabled } from '../../base/browser/browser.js';
import { isWindows, isLinux, isMacintosh, isWeb, isIOS } from '../../base/common/platform.js';
import { isWindows, isLinux, isMacintosh, isWeb, isIOS, isNative } from '../../base/common/platform.js';
import { EditorInputCapabilities, GroupIdentifier, isResourceEditorInput, IUntypedEditorInput, pathsToEditors } from '../common/editor.js';
import { SidebarPart } from './parts/sidebar/sidebarPart.js';
import { PanelPart } from './parts/panel/panelPart.js';
Expand Down Expand Up @@ -40,7 +40,7 @@ import { DiffEditorInput } from '../common/editor/diffEditorInput.js';
import { mark } from '../../base/common/performance.js';
import { IExtensionService } from '../services/extensions/common/extensions.js';
import { ILogService } from '../../platform/log/common/log.js';
import { DeferredPromise, Promises } from '../../base/common/async.js';
import { DeferredPromise, Promises, raceTimeout } from '../../base/common/async.js';
import { IBannerService } from '../services/banner/browser/bannerService.js';
import { IPaneCompositePartService } from '../services/panecomposite/browser/panecomposite.js';
import { AuxiliaryBarPart } from './parts/auxiliarybar/auxiliaryBarPart.js';
Expand Down Expand Up @@ -69,6 +69,12 @@ interface IEditorToOpen {
readonly viewColumn?: number;
}

interface IPartToggleWindowResize {
readonly editorSize: IViewSize;
readonly delta: { readonly width: number; readonly height: number };
readonly anchor: { readonly right: boolean; readonly bottom: boolean };
}

interface ILayoutInitializationState {
readonly views: {
readonly defaults: string[] | undefined;
Expand Down Expand Up @@ -1475,7 +1481,7 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi

this.setPanelHidden(true, true);
this.setAuxiliaryBarHidden(true, true);
this.setSideBarHidden(true);
this.setSideBarHidden(true, true);

if (config.hideActivityBar) {
this.setActivityBarHidden(true);
Expand Down Expand Up @@ -1558,7 +1564,7 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi
}

if (zenModeExitInfo.wasVisible.sideBar) {
this.setSideBarHidden(false);
this.setSideBarHidden(false, true);
}

if (!this.stateModel.getRuntimeValue(LayoutStateKeys.ACTIVITYBAR_HIDDEN, true)) {
Expand Down Expand Up @@ -1673,7 +1679,7 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi
// visibility efficiently.

if (part === sideBar) {
this.setSideBarHidden(!visible);
this.setSideBarHidden(!visible, true);
} else if (part === panelPart && this.stateModel.getRuntimeValue(LayoutStateKeys.PANEL_HIDDEN) === visible) {
this.setPanelHidden(!visible, true);
} else if (part === auxiliaryBarPart) {
Expand Down Expand Up @@ -1906,13 +1912,19 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi
]);
}

private setSideBarHidden(hidden: boolean): void {
private setSideBarHidden(hidden: boolean, skipLayout?: boolean): void {
if (!hidden && this.setAuxiliaryBarMaximized(false) && this.isVisible(Parts.SIDEBAR_PART)) {
return; // return: leaving maximised auxiliary bar made this part visible
}

const wasHidden = !this.isVisible(Parts.SIDEBAR_PART);

this.stateModel.setRuntimeValue(LayoutStateKeys.SIDEBAR_HIDDEN, hidden);

// Optionally resize the window so that the editor keeps its size when the
// primary side bar is shown or hidden (instead of the editor making room for it)
const sideBarToggleResize = this.getPartToggleWindowResize(this.sideBarPartView, this.getSideBarPosition(), hidden, wasHidden, skipLayout, false);

// Adjust CSS
if (hidden) {
this.mainContainer.classList.add(LayoutClasses.SIDEBAR_HIDDEN);
Expand All @@ -1939,6 +1951,11 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi
this.openViewContainer(ViewContainerLocation.Sidebar, viewletToOpen);
}
}

// Apply the window resize that keeps the editor size stable (no-op on web)
if (sideBarToggleResize) {
void this.resizeWindowToKeepEditorSize(sideBarToggleResize);
}
}

private hasViews(id: string): boolean {
Expand Down Expand Up @@ -2057,6 +2074,10 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi

const panelOpensMaximized = this.panelOpensMaximized();

// Optionally resize the window so that the editor keeps its size when
// the panel is shown or hidden (instead of the editor making room for it)
const panelToggleResize = this.getPartToggleWindowResize(this.panelPartView, this.getPanelPosition(), hidden, wasHidden, skipLayout, hidden ? isPanelMaximized : panelOpensMaximized);

// Adjust CSS
if (hidden) {
this.mainContainer.classList.add(LayoutClasses.PANEL_HIDDEN);
Expand Down Expand Up @@ -2121,6 +2142,87 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi
if (focusEditor) {
this.editorGroupService.mainPart.activeGroup.focus(); // Pass focus to editor group if panel part is now hidden
}

// Apply the window resize that keeps the editor size stable (no-op on web)
if (panelToggleResize) {
void this.resizeWindowToKeepEditorSize(panelToggleResize);
}
}

private getPartToggleWindowResize(partView: ISerializableView, position: Position, hidden: boolean, wasHidden: boolean, skipLayout: boolean | undefined, willBeMaximized: boolean): IPartToggleWindowResize | undefined {
const visibilityChanged = wasHidden !== hidden;
const keepEditorSizeEnabled = this.configurationService.getValue<boolean>(WorkbenchLayoutSettings.KEEP_EDITOR_SIZE_ON_TOGGLE);
const editorVisible = this.isVisible(Parts.EDITOR_PART, mainWindow);

// Window resizing is only available on desktop and only when the window is
// free to grow or shrink (not maximized or in full screen)
const canResizeWindow = isNative && !this.state.runtime.mainWindowFullscreen && !this.isWindowMaximized(mainWindow);

if (
skipLayout || // Skip programmatic visibility changes
willBeMaximized || // Editor is not visible when the part is maximized
this.inMaximizedAuxiliaryBarTransition || // Transition resizes all parts at once
!visibilityChanged ||
!keepEditorSizeEnabled ||
!canResizeWindow ||
!editorVisible
) {
return undefined;
}

const horizontal = isHorizontal(position);

// When hiding, the part is still visible in the grid, so use its current size
// When showing, the part is still hidden, so fall back to its last visible size
let partSizeAlongAxis: number;
if (hidden) {
const partSize = this.workbenchGrid.getViewSize(partView);
partSizeAlongAxis = horizontal ? partSize.height : partSize.width;
} else {
partSizeAlongAxis = this.workbenchGrid.getViewCachedVisibleSize(partView) ?? (horizontal ? partView.minimumHeight : partView.minimumWidth);
}

if (partSizeAlongAxis <= 0) {
return undefined;
}

const delta = hidden ? -partSizeAlongAxis : partSizeAlongAxis;

return {
editorSize: this.workbenchGrid.getViewSize(this.editorPartView),
delta: { width: horizontal ? 0 : delta, height: horizontal ? delta : 0 },
anchor: { right: position === Position.LEFT, bottom: position === Position.TOP }
};
}

private resizeWindowToKeepEditorSizeSeq = 0;

private async resizeWindowToKeepEditorSize(resize: IPartToggleWindowResize): Promise<void> {
const seq = ++this.resizeWindowToKeepEditorSizeSeq;

try {
const beforeWidth = this._mainContainerDimension.width;
const beforeHeight = this._mainContainerDimension.height;

const relayoutPromise = Event.toPromise(Event.once(Event.filter(this.onDidLayoutMainContainer, d => d.width !== beforeWidth || d.height !== beforeHeight)));

await this.hostService.resizeMainWindow(resize.delta, resize.anchor);

const didRelayout = await raceTimeout(relayoutPromise, 1000);

if (!didRelayout) {
return;
}
} catch (error) {
this.logService.warn('[layout] resizeWindowToKeepEditorSize failed', error);
return;
}

if (this.disposed || !this.workbenchGrid || seq !== this.resizeWindowToKeepEditorSizeSeq) {
return;
}

this.workbenchGrid.resizeView(this.editorPartView, { width: resize.editorSize.width, height: resize.editorSize.height });
}

private inMaximizedAuxiliaryBarTransition = false;
Expand Down Expand Up @@ -2249,8 +2351,15 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi
return; // return: leaving maximised auxiliary bar made this part hidden
}

const wasHidden = !this.isVisible(Parts.AUXILIARYBAR_PART);

this.stateModel.setRuntimeValue(LayoutStateKeys.AUXILIARYBAR_HIDDEN, hidden);

// Optionally resize the window so that the editor keeps its size when the
// secondary side bar is shown or hidden (instead of the editor making room for it).
// The secondary side bar is always on the opposite side of the primary side bar.
const auxiliaryBarToggleResize = this.getPartToggleWindowResize(this.auxiliaryBarPartView, this.getSideBarPosition() === Position.LEFT ? Position.RIGHT : Position.LEFT, hidden, wasHidden, skipLayout, false);

// Adjust CSS
if (hidden) {
this.mainContainer.classList.add(LayoutClasses.AUXILIARYBAR_HIDDEN);
Expand Down Expand Up @@ -2283,6 +2392,11 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi
this.openViewContainer(ViewContainerLocation.AuxiliaryBar, viewletToOpen, !skipLayout);
}
}

// Apply the window resize that keeps the editor size stable (no-op on web)
if (auxiliaryBarToggleResize) {
void this.resizeWindowToKeepEditorSize(auxiliaryBarToggleResize);
}
}

setPartHidden(hidden: boolean, part: Parts): void {
Expand Down Expand Up @@ -2860,6 +2974,7 @@ enum WorkbenchLayoutSettings {
ACTIVITY_BAR_VISIBLE = 'workbench.activityBar.visible',
PANEL_POSITION = 'workbench.panel.defaultLocation',
PANEL_OPENS_MAXIMIZED = 'workbench.panel.opensMaximized',
KEEP_EDITOR_SIZE_ON_TOGGLE = 'workbench.keepEditorSizeOnToggle',
ZEN_MODE_CONFIG = 'zenMode',
EDITOR_CENTERED_LAYOUT_AUTO_RESIZE = 'workbench.editor.centeredLayoutAutoResize',
EDITOR_RESTORE_EDITORS = 'workbench.editor.restoreEditors',
Expand Down
6 changes: 6 additions & 0 deletions src/vs/workbench/browser/workbench.contribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,12 @@ const registry = Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Con
],
agentsWindow: { default: 'never', readOnly: true },
},
'workbench.keepEditorSizeOnToggle': {
'type': 'boolean',
'default': false,
'description': localize('keepEditorSizeOnToggle', "Controls whether showing or hiding the panel, primary side bar, or secondary side bar resizes the window such that the editor keeps its size, instead of changing the editor size to make room for it. Only applies on desktop when the window is not maximized or in full screen."),
'included': !isWeb,
},
'workbench.secondarySideBar.defaultVisibility': {
'type': 'string',
'enum': ['hidden', 'visibleInWorkspace', 'visible', 'maximizedInWorkspace', 'maximized'],
Expand Down
Loading