Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow the QuickInput widget to be dragged #237534

Merged
merged 3 commits into from
Jan 9, 2025
Merged
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
28 changes: 27 additions & 1 deletion src/vs/platform/quickinput/browser/media/quickInput.css
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@
width: 600px;
z-index: 2550;
left: 50%;
margin-left: -300px;
-webkit-app-region: no-drag;
border-radius: 6px;
}

.quick-input-titlebar {
cursor: grab;
display: flex;
align-items: center;
border-top-right-radius: 5px;
Expand All @@ -37,6 +37,7 @@
}

.quick-input-title {
cursor: grab;
padding: 3px 0px;
text-align: center;
text-overflow: ellipsis;
Expand Down Expand Up @@ -69,6 +70,7 @@
}

.quick-input-header {
cursor: grab;
display: flex;
padding: 8px 6px 2px 6px;
}
Expand Down Expand Up @@ -362,3 +364,27 @@
.quick-input-list .monaco-tl-twistie {
display: none !important;
}

/* Quick input snap lines visible while DnD */
.quick-input-widget-snapline {
position: absolute;
z-index: 2549;
}

.quick-input-widget-snapline.hidden {
display: none;
}

.quick-input-widget-snapline.horizontal {
border-top: 1px dashed var(--vscode-editorRuler-foreground);
height: 0;
width: 100%;
left: 0;
}

.quick-input-widget-snapline.vertical {
border-left: 1px dashed var(--vscode-editorRuler-foreground);
height: 100%;
width: 0;
top: 0;
}
213 changes: 209 additions & 4 deletions src/vs/platform/quickinput/browser/quickInputController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,19 @@ import { IInstantiationService } from '../../instantiation/common/instantiation.
import { QuickInputTree } from './quickInputTree.js';
import { IContextKeyService } from '../../contextkey/common/contextkey.js';
import './quickInputActions.js';
import { autorun, observableValue } from '../../../base/common/observable.js';
import { StandardMouseEvent } from '../../../base/browser/mouseEvent.js';
import { IStorageService, StorageScope, StorageTarget } from '../../storage/common/storage.js';

const $ = dom.$;

const VIEWSTATE_STORAGE_KEY = 'workbench.quickInput.viewState';

type QuickInputViewState = {
readonly top?: number;
readonly left?: number;
};

export class QuickInputController extends Disposable {
private static readonly MAX_WIDTH = 600; // Max total width of quick input widget

Expand Down Expand Up @@ -58,6 +68,9 @@ export class QuickInputController extends Disposable {

private previousFocusElement?: HTMLElement;

private viewState: QuickInputViewState | undefined;
private dndController: QuickInputDragAndDropController | undefined;

private readonly inQuickInputContext = InQuickInputContextKey.bindTo(this.contextKeyService);
private readonly quickInputTypeContext = QuickInputTypeContextKey.bindTo(this.contextKeyService);
private readonly endOfQuickInputBoxContext = EndOfQuickInputBoxContextKey.bindTo(this.contextKeyService);
Expand All @@ -66,7 +79,8 @@ export class QuickInputController extends Disposable {
private options: IQuickInputOptions,
@ILayoutService private readonly layoutService: ILayoutService,
@IInstantiationService private readonly instantiationService: IInstantiationService,
@IContextKeyService private readonly contextKeyService: IContextKeyService
@IContextKeyService private readonly contextKeyService: IContextKeyService,
@IStorageService private readonly storageService: IStorageService
) {
super();
this.idPrefix = options.idPrefix;
Expand All @@ -83,6 +97,7 @@ export class QuickInputController extends Disposable {
this.layout(this.layoutService.mainContainerDimension, this.layoutService.mainContainerOffset.quickPickTop);
}
}));
this.viewState = this.loadViewState();
}

private registerKeyModsListeners(window: Window, disposables: DisposableStore): void {
Expand Down Expand Up @@ -314,6 +329,36 @@ export class QuickInputController extends Disposable {
}
}));

// Drag and Drop support
this.dndController = this._register(this.instantiationService.createInstance(
QuickInputDragAndDropController, this._container, container, [titleBar, title, headerContainer]));

// DnD update layout
this._register(autorun(reader => {
const dndViewState = this.dndController?.dndViewState.read(reader);
if (!dndViewState) {
return;
}

if (dndViewState.top !== undefined && dndViewState.left !== undefined) {
this.viewState = {
...this.viewState,
top: dndViewState.top,
left: dndViewState.left
};
} else {
// Reset position/size
this.viewState = undefined;
}

this.updateLayout();

// Save position
if (dndViewState.done) {
this.saveViewState(this.viewState);
}
}));

this.ui = {
container,
styleSheet,
Expand Down Expand Up @@ -360,6 +405,7 @@ export class QuickInputController extends Disposable {
if (this.ui) {
this._container = container;
dom.append(this._container, this.ui.container);
this.dndController?.reparentUI(this._container);
}
}

Expand Down Expand Up @@ -729,12 +775,13 @@ export class QuickInputController extends Disposable {

private updateLayout() {
if (this.ui && this.isVisible()) {
this.ui.container.style.top = `${this.titleBarOffset}px`;

const style = this.ui.container.style;
const width = Math.min(this.dimension!.width * 0.62 /* golden cut */, QuickInputController.MAX_WIDTH);
style.width = width + 'px';
style.marginLeft = '-' + (width / 2) + 'px';

// Position
style.top = `${this.viewState?.top ? Math.round(this.dimension!.height * this.viewState.top) : this.titleBarOffset}px`;
style.left = `${Math.round((this.dimension!.width * (this.viewState?.left ?? 0.5 /* center */)) - (width / 2))}px`;

this.ui.inputBox.layout();
this.ui.list.layout(this.dimension && this.dimension.height * 0.4);
Expand Down Expand Up @@ -800,6 +847,164 @@ export class QuickInputController extends Disposable {
}
}
}

private loadViewState(): QuickInputViewState | undefined {
try {
const data = JSON.parse(this.storageService.get(VIEWSTATE_STORAGE_KEY, StorageScope.APPLICATION, '{}'));
if (data.top !== undefined || data.left !== undefined) {
return data;
}
} catch { }

return undefined;
}

private saveViewState(viewState: QuickInputViewState | undefined): void {
const isMainWindow = this.layoutService.activeContainer === this.layoutService.mainContainer;
if (!isMainWindow) {
return;
}

if (viewState !== undefined) {
this.storageService.store(VIEWSTATE_STORAGE_KEY, JSON.stringify(viewState), StorageScope.APPLICATION, StorageTarget.MACHINE);
} else {
this.storageService.remove(VIEWSTATE_STORAGE_KEY, StorageScope.APPLICATION);
}
}
}

export interface IQuickInputControllerHost extends ILayoutService { }

class QuickInputDragAndDropController extends Disposable {
readonly dndViewState = observableValue<{ top?: number; left?: number; done: boolean } | undefined>(this, undefined);

private readonly _snapThreshold = 20;
private readonly _snapLineHorizontalRatio = 0.15;
private readonly _snapLineHorizontal: HTMLElement;
private readonly _snapLineVertical1: HTMLElement;
private readonly _snapLineVertical2: HTMLElement;

constructor(
private _container: HTMLElement,
private readonly _quickInputContainer: HTMLElement,
private _quickInputDragAreas: HTMLElement[],
@ILayoutService private readonly _layoutService: ILayoutService
) {
super();

this._snapLineHorizontal = dom.append(this._container, $('.quick-input-widget-snapline.horizontal.hidden'));
this._snapLineVertical1 = dom.append(this._container, $('.quick-input-widget-snapline.vertical.hidden'));
this._snapLineVertical2 = dom.append(this._container, $('.quick-input-widget-snapline.vertical.hidden'));

this.registerMouseListeners();
}

reparentUI(container: HTMLElement): void {
this._container = container;
this._snapLineHorizontal.remove();
this._snapLineVertical1.remove();
this._snapLineVertical2.remove();
dom.append(this._container, this._snapLineHorizontal);
dom.append(this._container, this._snapLineVertical1);
dom.append(this._container, this._snapLineVertical2);
}

private registerMouseListeners(): void {
for (const dragArea of this._quickInputDragAreas) {
let top: number | undefined;
let left: number | undefined;

// Double click
this._register(dom.addDisposableGenericMouseUpListener(dragArea, (event: MouseEvent) => {
const originEvent = new StandardMouseEvent(dom.getWindow(dragArea), event);

// Ignore event if the target is not the drag area
if (originEvent.target !== dragArea) {
return;
}

if (originEvent.detail === 2) {
top = undefined;
left = undefined;

this.dndViewState.set({ top, left, done: true }, undefined);
}
}));

// Mouse down
this._register(dom.addDisposableGenericMouseDownListener(dragArea, (e: MouseEvent) => {
const activeWindow = dom.getWindow(this._layoutService.activeContainer);
const originEvent = new StandardMouseEvent(activeWindow, e);

// Ignore event if the target is not the drag area
if (originEvent.target !== dragArea) {
return;
}

// Mouse position offset relative to dragArea
const dragAreaRect = this._quickInputContainer.getBoundingClientRect();
const dragOffsetX = originEvent.browserEvent.clientX - dragAreaRect.left;
const dragOffsetY = originEvent.browserEvent.clientY - dragAreaRect.top;

// Snap lines
let snapLinesVisible = false;
const snapCoordinateYTop = this._layoutService.activeContainerOffset.quickPickTop;
const snapCoordinateY = Math.round(this._container.clientHeight * this._snapLineHorizontalRatio);
const snapCoordinateX = Math.round(this._container.clientWidth / 2) - Math.round(this._quickInputContainer.clientWidth / 2);

// Mouse move
const mouseMoveListener = dom.addDisposableGenericMouseMoveListener(activeWindow, (e: MouseEvent) => {
const mouseMoveEvent = new StandardMouseEvent(activeWindow, e);
mouseMoveEvent.preventDefault();

if (!snapLinesVisible) {
this._showSnapLines(snapCoordinateY, snapCoordinateX);
snapLinesVisible = true;
}

let topCoordinate = e.clientY - dragOffsetY;
topCoordinate = Math.max(0, Math.min(topCoordinate, this._container.clientHeight - this._quickInputContainer.clientHeight));
topCoordinate = Math.abs(topCoordinate - snapCoordinateYTop) < this._snapThreshold ? snapCoordinateYTop : topCoordinate;
topCoordinate = Math.abs(topCoordinate - snapCoordinateY) < this._snapThreshold ? snapCoordinateY : topCoordinate;
top = topCoordinate / this._container.clientHeight;

let leftCoordinate = e.clientX - dragOffsetX;
leftCoordinate = Math.max(0, Math.min(leftCoordinate, this._container.clientWidth - this._quickInputContainer.clientWidth));
leftCoordinate = Math.abs(leftCoordinate - snapCoordinateX) < this._snapThreshold ? snapCoordinateX : leftCoordinate;
left = (leftCoordinate + (this._quickInputContainer.clientWidth / 2)) / this._container.clientWidth;

this.dndViewState.set({ top, left, done: false }, undefined);
});

// Mouse up
const mouseUpListener = dom.addDisposableGenericMouseUpListener(activeWindow, (e: MouseEvent) => {
// Hide snaplines
this._hideSnapLines();

// Save position
this.dndViewState.set({ top, left, done: true }, undefined);

// Dispose listeners
mouseMoveListener.dispose();
mouseUpListener.dispose();
});
}));
}
}

private _showSnapLines(horizontal: number, vertical: number) {
this._snapLineHorizontal.style.top = `${horizontal}px`;
this._snapLineVertical1.style.left = `${vertical}px`;
this._snapLineVertical2.style.left = `${vertical + this._quickInputContainer.clientWidth}px`;

this._snapLineHorizontal.classList.remove('hidden');
this._snapLineVertical1.classList.remove('hidden');
this._snapLineVertical2.classList.remove('hidden');
}

private _hideSnapLines() {
this._snapLineHorizontal.classList.add('hidden');
this._snapLineVertical1.classList.add('hidden');
this._snapLineVertical2.classList.add('hidden');
}
}
Loading