From 232768306ad655e7beb318a04f358ca3056dff4b Mon Sep 17 00:00:00 2001 From: Tyler James Leonhardt Date: Wed, 8 Jan 2025 20:42:13 -0800 Subject: [PATCH] Allow the QuickInput widget to be dragged (#237534) * Allow the QuickInput widget to be dragged Thanks to @lszomoru for the initial prototype! That also had resizing but there were a couple edge cases that made it not quite ready. However, the drag-n-drop is really quite polished so I wanted to get this out for folks to try and I don't think it needs a setting. Next steps: * Add a snap to the top (aka the original location) * Resize... Fixes https://github.com/microsoft/vscode/issues/17268 * Add top snap * don't allow it to go off screen --- .../quickinput/browser/media/quickInput.css | 28 ++- .../browser/quickInputController.ts | 213 +++++++++++++++++- 2 files changed, 236 insertions(+), 5 deletions(-) diff --git a/src/vs/platform/quickinput/browser/media/quickInput.css b/src/vs/platform/quickinput/browser/media/quickInput.css index 53f42dfa5ae26..24394ac42cbb9 100644 --- a/src/vs/platform/quickinput/browser/media/quickInput.css +++ b/src/vs/platform/quickinput/browser/media/quickInput.css @@ -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; @@ -37,6 +37,7 @@ } .quick-input-title { + cursor: grab; padding: 3px 0px; text-align: center; text-overflow: ellipsis; @@ -69,6 +70,7 @@ } .quick-input-header { + cursor: grab; display: flex; padding: 8px 6px 2px 6px; } @@ -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; +} diff --git a/src/vs/platform/quickinput/browser/quickInputController.ts b/src/vs/platform/quickinput/browser/quickInputController.ts index 7b77f077c03ce..783dde2dd5bd4 100644 --- a/src/vs/platform/quickinput/browser/quickInputController.ts +++ b/src/vs/platform/quickinput/browser/quickInputController.ts @@ -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 @@ -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); @@ -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; @@ -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 { @@ -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, @@ -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); } } @@ -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); @@ -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'); + } +}