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

Sticky Scroll Tree View #198320

Merged
merged 27 commits into from
Nov 21, 2023
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
ac80a13
Tree Sticky Scroll
benibenj Nov 15, 2023
5d3866e
Updated StickyScrollController and TreeRenderer classes for improved
benibenj Nov 15, 2023
ec8f320
Merge branch 'main' into benibenj/treeStickyScroll
benibenj Nov 15, 2023
1d8de70
Updated TreeNodeListMouseController to handle sticky elements and
benibenj Nov 16, 2023
2af5515
:lipstick:
benibenj Nov 16, 2023
c8e5d58
fix max-ratio corner case
benibenj Nov 16, 2023
8d0b4a6
Updated various components for better visibility and scrolling in trees,
benibenj Nov 16, 2023
afb2097
:lipstick:
benibenj Nov 17, 2023
e0b04b5
Refactor StickyScrollController and update CSS selectors in tree.css
benibenj Nov 18, 2023
e35189f
Don't keep empty arrays around
benibenj Nov 19, 2023
4c24d98
SCM InputRenderer
benibenj Nov 19, 2023
630a9f8
SCM Action Button
benibenj Nov 19, 2023
3d0dbcb
Resource Markers
benibenj Nov 19, 2023
eaecf45
compressed explorer view
benibenj Nov 19, 2023
2e3c37d
handle disposable
benibenj Nov 20, 2023
b159c5b
:lipstick:
benibenj Nov 20, 2023
efcce4e
:lipstick:
benibenj Nov 20, 2023
2c13344
Merge branch 'main' into benibenj/treeStickyScroll
benibenj Nov 20, 2023
0566ecd
:lipstick:
benibenj Nov 20, 2023
2e28417
fix colors
benibenj Nov 20, 2023
d7a3050
:lipstick:
benibenj Nov 20, 2023
7f63c62
:lipstick:
benibenj Nov 21, 2023
19e68a0
Updated list and tree UI components to support sticky scrolling and
benibenj Nov 21, 2023
c856c39
Updated getRelativeTop method to consider paddingTop and adjusted
benibenj Nov 21, 2023
32bd56f
Updated CSS and TypeScript for improved scrollbar and sticky scroll
benibenj Nov 21, 2023
252be64
Updated scroll calculations and added node position methods in list and
benibenj Nov 21, 2023
13b6a07
Merge branch 'main' into benibenj/treeStickyScroll
benibenj Nov 21, 2023
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
6 changes: 6 additions & 0 deletions src/vs/base/browser/ui/list/listView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ export interface IListView<T> extends ISpliceable<T>, IDisposable {
readonly renderHeight: number;
readonly scrollHeight: number;
readonly firstVisibleIndex: number;
readonly firstHalfVisibleIndex: number;
readonly lastVisibleIndex: number;
onDidScroll: Event<ScrollEvent>;
onWillScroll: Event<ScrollEvent>;
Expand Down Expand Up @@ -752,6 +753,11 @@ export class ListView<T> implements IListView<T> {
}

get firstVisibleIndex(): number {
const range = this.getRenderRange(this.lastRenderTop, this.lastRenderHeight);
return range.start;
}

get firstHalfVisibleIndex(): number {
const range = this.getRenderRange(this.lastRenderTop, this.lastRenderHeight);
const firstElTop = this.rangeMap.positionAt(range.start);
benibenj marked this conversation as resolved.
Show resolved Hide resolved
const nextElTop = this.rangeMap.positionAt(range.start + 1);
Expand Down
38 changes: 35 additions & 3 deletions src/vs/base/browser/ui/list/listWidget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,8 +256,8 @@ export function isInputElement(e: HTMLElement): boolean {
return e.tagName === 'INPUT' || e.tagName === 'TEXTAREA';
}

export function isMonacoEditor(e: HTMLElement): boolean {
if (e.classList.contains('monaco-editor')) {
function isListElementDescendantOfClass(e: HTMLElement, className: string): boolean {
if (e.classList.contains(className)) {
return true;
}

Expand All @@ -269,7 +269,27 @@ export function isMonacoEditor(e: HTMLElement): boolean {
return false;
}

return isMonacoEditor(e.parentElement);
return isListElementDescendantOfClass(e.parentElement, className);
}

export function isMonacoEditor(e: HTMLElement): boolean {
return isListElementDescendantOfClass(e, 'monaco-editor');
}

export function isMonacoCustomToggle(e: HTMLElement): boolean {
return isListElementDescendantOfClass(e, 'monaco-custom-toggle');
}

export function isActionItem(e: HTMLElement): boolean {
return isListElementDescendantOfClass(e, 'action-item');
}

export function isMonacoTwistie(e: HTMLElement): boolean {
return isListElementDescendantOfClass(e, 'monaco-tl-twistie');
}

export function isStickyScrollElement(e: HTMLElement): boolean {
return isListElementDescendantOfClass(e, 'sticky-element');
}

export function isButton(e: HTMLElement): boolean {
Expand Down Expand Up @@ -1598,6 +1618,10 @@ export class List<T> implements ISpliceable<T>, IDisposable {
return this.view.firstVisibleIndex;
}

get firstHalfVisibleIndex(): number {
return this.view.firstHalfVisibleIndex;
}

get lastVisibleIndex(): number {
return this.view.lastVisibleIndex;
}
Expand Down Expand Up @@ -1887,10 +1911,18 @@ export class List<T> implements ISpliceable<T>, IDisposable {
return this.view.domNode;
}

getScrollableElement(): HTMLElement {
return this.view.scrollableElementDomNode;
}

getElementID(index: number): string {
return this.view.getElementDomId(index);
}

getElementTop(index: number): number {
return this.view.elementTop(index);
}

style(styles: IListStyles): void {
this.styleController.style(styles);
}
Expand Down
53 changes: 41 additions & 12 deletions src/vs/base/browser/ui/tree/abstractTree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { FindInput } from 'vs/base/browser/ui/findinput/findInput';
import { IInputBoxStyles, IMessage, MessageType, unthemedInboxStyles } from 'vs/base/browser/ui/inputbox/inputBox';
import { IIdentityProvider, IKeyboardNavigationLabelProvider, IListContextMenuEvent, IListDragAndDrop, IListDragOverReaction, IListMouseEvent, IListRenderer, IListVirtualDelegate } from 'vs/base/browser/ui/list/list';
import { ElementsDragAndDropData } from 'vs/base/browser/ui/list/listView';
import { IListOptions, IListStyles, isButton, isInputElement, isMonacoEditor, List, MouseController, TypeNavigationMode } from 'vs/base/browser/ui/list/listWidget';
import { IListOptions, IListStyles, isButton, isInputElement, isMonacoEditor, isStickyScrollElement, List, MouseController, TypeNavigationMode } from 'vs/base/browser/ui/list/listWidget';
import { IToggleStyles, Toggle, unthemedToggleStyles } from 'vs/base/browser/ui/toggle/toggle';
import { getVisibleState, isFilterResult } from 'vs/base/browser/ui/tree/indexTreeModel';
import { ICollapseStateChangeEvent, ITreeContextMenuEvent, ITreeDragAndDrop, ITreeEvent, ITreeFilter, ITreeModel, ITreeModelSpliceEvent, ITreeMouseEvent, ITreeNavigator, ITreeNode, ITreeRenderer, TreeDragOverBubble, TreeError, TreeFilterResult, TreeMouseEventTarget, TreeVisibility } from 'vs/base/browser/ui/tree/tree';
Expand All @@ -33,6 +33,7 @@ import { ISpliceable } from 'vs/base/common/sequence';
import { isNumber } from 'vs/base/common/types';
import 'vs/css!./media/tree';
import { localize } from 'vs/nls';
import { StickyScrollController } from 'vs/base/browser/ui/tree/stickyScroll';

class TreeElementsDragAndDropData<T, TFilterData, TContext> extends ElementsDragAndDropData<T, TContext> {

Expand Down Expand Up @@ -327,7 +328,7 @@ class EventCollection<T> implements Collection<T>, IDisposable {
}
}

class TreeRenderer<T, TFilterData, TRef, TTemplateData> implements IListRenderer<ITreeNode<T, TFilterData>, ITreeListTemplateData<TTemplateData>> {
export class TreeRenderer<T, TFilterData, TRef, TTemplateData> implements IListRenderer<ITreeNode<T, TFilterData>, ITreeListTemplateData<TTemplateData>> {

private static readonly DefaultIndent = 8;

Expand Down Expand Up @@ -1212,6 +1213,8 @@ export interface IAbstractTreeOptionsUpdate extends ITreeRendererOptions {
readonly fastScrollSensitivity?: number;
readonly expandOnDoubleClick?: boolean;
readonly expandOnlyOnTwistieClick?: boolean | ((e: any) => boolean); // e is T
readonly enableStickyScroll?: boolean;
readonly stickyScrollMaxItemCount?: number;
}

export interface IAbstractTreeOptions<T, TFilterData = void> extends IAbstractTreeOptionsUpdate, IListOptions<T> {
Expand Down Expand Up @@ -1377,24 +1380,30 @@ class TreeNodeListMouseController<T, TFilterData, TRef> extends MouseController<
const target = e.browserEvent.target as HTMLElement;
const onTwistie = target.classList.contains('monaco-tl-twistie')
|| (target.classList.contains('monaco-icon-label') && target.classList.contains('folder-icon') && e.browserEvent.offsetX < 16);
const isStickyElement = isStickyScrollElement(e.browserEvent.target as HTMLElement);

let expandOnlyOnTwistieClick = false;

if (typeof this.tree.expandOnlyOnTwistieClick === 'function') {
if (isStickyElement) {
expandOnlyOnTwistieClick = true;
}
else if (typeof this.tree.expandOnlyOnTwistieClick === 'function') {
expandOnlyOnTwistieClick = this.tree.expandOnlyOnTwistieClick(node.element);
} else {
expandOnlyOnTwistieClick = !!this.tree.expandOnlyOnTwistieClick;
}

if (expandOnlyOnTwistieClick && !onTwistie && e.browserEvent.detail !== 2) {
return super.onViewPointer(e);
}
if (!isStickyElement) {
if (expandOnlyOnTwistieClick && !onTwistie && e.browserEvent.detail !== 2) {
return super.onViewPointer(e);
}

if (!this.tree.expandOnDoubleClick && e.browserEvent.detail === 2) {
return super.onViewPointer(e);
if (!this.tree.expandOnDoubleClick && e.browserEvent.detail === 2) {
return super.onViewPointer(e);
}
}

if (node.collapsible) {
if (node.collapsible && (!isStickyElement || onTwistie)) {
const location = this.tree.getNodeLocation(node);
const recursive = e.browserEvent.altKey;
this.tree.setFocus([location]);
Expand All @@ -1407,7 +1416,9 @@ class TreeNodeListMouseController<T, TFilterData, TRef> extends MouseController<
}
}

super.onViewPointer(e);
if (!isStickyElement) {
super.onViewPointer(e);
}
}

protected override onDoubleClick(e: IListMouseEvent<ITreeNode<T, TFilterData>>): void {
Expand Down Expand Up @@ -1524,13 +1535,15 @@ export abstract class AbstractTree<T, TFilterData, TRef> implements IDisposable
protected view: TreeNodeList<T, TFilterData, TRef>;
private renderers: TreeRenderer<T, TFilterData, TRef, any>[];
protected model: ITreeModel<T, TFilterData, TRef>;
private treeDelegate: ComposedTreeDelegate<T, ITreeNode<T, TFilterData>>;
private focus: Trait<T>;
private selection: Trait<T>;
private anchor: Trait<T>;
private eventBufferer = new EventBufferer();
private findController?: FindController<T, TFilterData>;
readonly onDidChangeFindOpenState: Event<boolean> = Event.None;
private focusNavigationFilter: ((node: ITreeNode<T, TFilterData>) => boolean) | undefined;
private stickyScrollController?: StickyScrollController<T, TFilterData, TRef>;
private styleElement: HTMLStyleElement;
protected readonly disposables = new DisposableStore();

Expand Down Expand Up @@ -1584,7 +1597,7 @@ export abstract class AbstractTree<T, TFilterData, TRef> implements IDisposable
renderers: ITreeRenderer<T, TFilterData, any>[],
private _options: IAbstractTreeOptions<T, TFilterData> = {}
) {
const treeDelegate = new ComposedTreeDelegate<T, ITreeNode<T, TFilterData>>(delegate);
this.treeDelegate = new ComposedTreeDelegate<T, ITreeNode<T, TFilterData>>(delegate);

const onDidChangeCollapseStateRelay = new Relay<ICollapseStateChangeEvent<T, TFilterData>>();
const onDidChangeActiveNodes = new Relay<ITreeNode<T, TFilterData>[]>();
Expand All @@ -1606,7 +1619,7 @@ export abstract class AbstractTree<T, TFilterData, TRef> implements IDisposable
this.focus = new Trait(() => this.view.getFocusedElements()[0], _options.identityProvider);
this.selection = new Trait(() => this.view.getSelectedElements()[0], _options.identityProvider);
this.anchor = new Trait(() => this.view.getAnchorElement(), _options.identityProvider);
this.view = new TreeNodeList(_user, container, treeDelegate, this.renderers, this.focus, this.selection, this.anchor, { ...asListOptions(() => this.model, _options), tree: this });
this.view = new TreeNodeList(_user, container, this.treeDelegate, this.renderers, this.focus, this.selection, this.anchor, { ...asListOptions(() => this.model, _options), tree: this });

this.model = this.createModel(_user, this.view, _options);
onDidChangeCollapseStateRelay.input = this.model.onDidChangeCollapseState;
Expand Down Expand Up @@ -1668,6 +1681,10 @@ export abstract class AbstractTree<T, TFilterData, TRef> implements IDisposable
this.onDidChangeFindMatchType = Event.None;
}

if (_options.enableStickyScroll) {
this.stickyScrollController = new StickyScrollController(this, this.model, this.view, this.renderers, this.treeDelegate, _options);
}

this.styleElement = createStyleSheet(this.view.getHTMLElement());
this.getHTMLElement().classList.toggle('always', this._options.renderIndentGuides === RenderIndentGuides.Always);
}
Expand All @@ -1681,6 +1698,7 @@ export abstract class AbstractTree<T, TFilterData, TRef> implements IDisposable

this.view.updateOptions(this._options);
this.findController?.updateOptions(optionsUpdate);
this.updateStickyScroll(optionsUpdate);

this._onDidUpdateOptions.fire(this._options);

Expand All @@ -1691,6 +1709,16 @@ export abstract class AbstractTree<T, TFilterData, TRef> implements IDisposable
return this._options;
}

private updateStickyScroll(optionsUpdate: IAbstractTreeOptionsUpdate) {
if (!this.stickyScrollController && this._options.enableStickyScroll) {
this.stickyScrollController = new StickyScrollController(this, this.model, this.view, this.renderers, this.treeDelegate, this._options);
} else if (this.stickyScrollController && !this._options.enableStickyScroll) {
this.stickyScrollController.dispose();
this.stickyScrollController = undefined;
}
this.stickyScrollController?.updateOptions(optionsUpdate);
}

updateWidth(element: TRef): void {
const index = this.model.getListIndex(element);

Expand Down Expand Up @@ -2086,6 +2114,7 @@ export abstract class AbstractTree<T, TFilterData, TRef> implements IDisposable

dispose(): void {
dispose(this.disposables);
this.stickyScrollController?.dispose();
this.view.dispose();
}
}
Expand Down
43 changes: 43 additions & 0 deletions src/vs/base/browser/ui/tree/media/stickyScroll.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

.sticky-tree-widget{
benibenj marked this conversation as resolved.
Show resolved Hide resolved
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 0;

background-color: var(--vscode-sideBar-background);
z-index: 4;
}

.sticky-tree-widget .sticky-element{
position: absolute;
width: 100%;
height: 22px;
line-height: 22px;

background-color: var(--vscode-sideBar-background);
z-index: 5;
benibenj marked this conversation as resolved.
Show resolved Hide resolved
}

.sticky-tree-widget .sticky-element.last-sticky{
z-index: 4;
}

.sticky-tree-widget .sticky-element:hover{
background-color: var(--vscode-list-hoverBackground);
cursor: pointer;
}

.sticky-tree-widget .sticky-tree-widget-shadow{
position: absolute;
bottom: -3px;
left: 0px;
height: 3px;
width: 100%;
box-shadow: var(--vscode-scrollbar-shadow) 0 6px 6px -6px inset;
}
Loading
Loading