Skip to content

Commit

Permalink
Introduce minimap section headers, a la Xcode (#190759)
Browse files Browse the repository at this point in the history
* WIP for adding minimap section headers for #74843

* Get section headers rendering

* Fix default value of section header font size

* Fix tests

* Improve section header position

* Fix separator display, update after config change

* Split too-long headers with an ellipsis

* Render section headers on the decorations canvas

* Support MARK with just a separator line

* Calculate minimap section headers asynchronously

* Simplify change

* Avoid font variable duplication

* Fix issue introduced earlier

* Recompute section headers when the language configuration changes

* Fix problem in constructing region header range

* Parse mark headers in the entire file and then filter out the ones not appearing in comments on the UI side, where tokens info is available

---------

Co-authored-by: Alexandru Dima <alexdima@microsoft.com>
  • Loading branch information
dgileadi and alexdima authored Mar 18, 2024
1 parent cda53a0 commit c800bf9
Show file tree
Hide file tree
Showing 22 changed files with 637 additions and 31 deletions.
16 changes: 16 additions & 0 deletions src/vs/base/browser/fonts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { isMacintosh, isWindows } from 'vs/base/common/platform';

/**
* The best font-family to be used in CSS based on the platform:
* - Windows: Segoe preferred, fallback to sans-serif
* - macOS: standard system font, fallback to sans-serif
* - Linux: standard system font preferred, fallback to Ubuntu fonts
*
* Note: this currently does not adjust for different locales.
*/
export const DEFAULT_FONT_FAMILY = isWindows ? '"Segoe WPC", "Segoe UI", sans-serif' : isMacintosh ? '-apple-system, BlinkMacSystemFont, sans-serif' : 'system-ui, "Ubuntu", "Droid Sans", sans-serif';
11 changes: 11 additions & 0 deletions src/vs/editor/browser/services/editorWorkerService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { IDocumentDiff, IDocumentDiffProviderOptions } from 'vs/editor/common/di
import { ILinesDiffComputerOptions, MovedText } from 'vs/editor/common/diff/linesDiffComputer';
import { DetailedLineRangeMapping, RangeMapping, LineRangeMapping } from 'vs/editor/common/diff/rangeMapping';
import { LineRange } from 'vs/editor/common/core/lineRange';
import { SectionHeader, FindSectionHeaderOptions } from 'vs/editor/common/services/findSectionHeaders';
import { mainWindow } from 'vs/base/browser/window';
import { WindowIntervalTimer } from 'vs/base/browser/dom';

Expand Down Expand Up @@ -190,6 +191,10 @@ export class EditorWorkerService extends Disposable implements IEditorWorkerServ
computeWordRanges(resource: URI, range: IRange): Promise<{ [word: string]: IRange[] } | null> {
return this._workerManager.withWorker().then(client => client.computeWordRanges(resource, range));
}

public findSectionHeaders(uri: URI, options: FindSectionHeaderOptions): Promise<SectionHeader[]> {
return this._workerManager.withWorker().then(client => client.findSectionHeaders(uri, options));
}
}

class WordBasedCompletionItemProvider implements languages.CompletionItemProvider {
Expand Down Expand Up @@ -613,6 +618,12 @@ export class EditorWorkerClient extends Disposable implements IEditorWorkerClien
});
}

public findSectionHeaders(uri: URI, options: FindSectionHeaderOptions): Promise<SectionHeader[]> {
return this._withSyncedResources([uri]).then(proxy => {
return proxy.findSectionHeaders(uri.toString(), options);
});
}

override dispose(): void {
super.dispose();
this._disposed = true;
Expand Down
178 changes: 166 additions & 12 deletions src/vs/editor/browser/viewParts/minimap/minimap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,16 @@ import { ViewContext } from 'vs/editor/common/viewModel/viewContext';
import { EditorTheme } from 'vs/editor/common/editorTheme';
import * as viewEvents from 'vs/editor/common/viewEvents';
import { ViewLineData, ViewModelDecoration } from 'vs/editor/common/viewModel';
import { minimapSelection, minimapBackground, minimapForegroundOpacity } from 'vs/platform/theme/common/colorRegistry';
import { minimapSelection, minimapBackground, minimapForegroundOpacity, editorForeground } from 'vs/platform/theme/common/colorRegistry';
import { ModelDecorationMinimapOptions } from 'vs/editor/common/model/textModel';
import { Selection } from 'vs/editor/common/core/selection';
import { Color } from 'vs/base/common/color';
import { GestureEvent, EventType, Gesture } from 'vs/base/browser/touch';
import { MinimapCharRendererFactory } from 'vs/editor/browser/viewParts/minimap/minimapCharRendererFactory';
import { MinimapPosition, TextModelResolvedOptions } from 'vs/editor/common/model';
import { MinimapPosition, MinimapSectionHeaderStyle, TextModelResolvedOptions } from 'vs/editor/common/model';
import { createSingleCallFunction } from 'vs/base/common/functional';
import { LRUCache } from 'vs/base/common/map';
import { DEFAULT_FONT_FAMILY } from 'vs/base/browser/fonts';

/**
* The orthogonal distance to the slider at which dragging "resets". This implements "snapping"
Expand Down Expand Up @@ -90,6 +92,9 @@ class MinimapOptions {
public readonly fontScale: number;
public readonly minimapLineHeight: number;
public readonly minimapCharWidth: number;
public readonly sectionHeaderFontFamily: string;
public readonly sectionHeaderFontSize: number;
public readonly sectionHeaderFontColor: RGBA8;

public readonly charRenderer: () => MinimapCharRenderer;
public readonly defaultBackgroundColor: RGBA8;
Expand Down Expand Up @@ -132,6 +137,9 @@ class MinimapOptions {
this.fontScale = minimapLayout.minimapScale;
this.minimapLineHeight = minimapLayout.minimapLineHeight;
this.minimapCharWidth = Constants.BASE_CHAR_WIDTH * this.fontScale;
this.sectionHeaderFontFamily = DEFAULT_FONT_FAMILY;
this.sectionHeaderFontSize = minimapOpts.sectionHeaderFontSize * pixelRatio;
this.sectionHeaderFontColor = MinimapOptions._getSectionHeaderColor(theme, tokensColorTracker.getColor(ColorId.DefaultForeground));

this.charRenderer = createSingleCallFunction(() => MinimapCharRendererFactory.create(this.fontScale, fontInfo.fontFamily));
this.defaultBackgroundColor = tokensColorTracker.getColor(ColorId.DefaultBackground);
Expand All @@ -155,6 +163,14 @@ class MinimapOptions {
return 255;
}

private static _getSectionHeaderColor(theme: EditorTheme, defaultForegroundColor: RGBA8): RGBA8 {
const themeColor = theme.getColor(editorForeground);
if (themeColor) {
return new RGBA8(themeColor.rgba.r, themeColor.rgba.g, themeColor.rgba.b, Math.round(255 * themeColor.rgba.a));
}
return defaultForegroundColor;
}

public equals(other: MinimapOptions): boolean {
return (this.renderMinimap === other.renderMinimap
&& this.size === other.size
Expand All @@ -179,6 +195,7 @@ class MinimapOptions {
&& this.fontScale === other.fontScale
&& this.minimapLineHeight === other.minimapLineHeight
&& this.minimapCharWidth === other.minimapCharWidth
&& this.sectionHeaderFontSize === other.sectionHeaderFontSize
&& this.defaultBackgroundColor && this.defaultBackgroundColor.equals(other.defaultBackgroundColor)
&& this.backgroundColor && this.backgroundColor.equals(other.backgroundColor)
&& this.foregroundAlpha === other.foregroundAlpha
Expand Down Expand Up @@ -544,6 +561,8 @@ export interface IMinimapModel {
getMinimapLinesRenderingData(startLineNumber: number, endLineNumber: number, needed: boolean[]): (ViewLineData | null)[];
getSelections(): Selection[];
getMinimapDecorationsInViewport(startLineNumber: number, endLineNumber: number): ViewModelDecoration[];
getSectionHeaderDecorationsInViewport(startLineNumber: number, endLineNumber: number): ViewModelDecoration[];
getSectionHeaderText(decoration: ViewModelDecoration, fitWidth: (s: string) => string): string | null;
getOptions(): TextModelResolvedOptions;
revealLineNumber(lineNumber: number): void;
setScrollTop(scrollTop: number): void;
Expand Down Expand Up @@ -697,7 +716,7 @@ class MinimapSamplingState {

constructor(
public readonly samplingRatio: number,
public readonly minimapLines: number[]
public readonly minimapLines: number[] // a map of 0-based minimap line indexes to 1-based view line numbers
) {
}

Expand Down Expand Up @@ -790,6 +809,8 @@ export class Minimap extends ViewPart implements IMinimapModel {
private _samplingState: MinimapSamplingState | null;
private _shouldCheckSampling: boolean;

private _sectionHeaderCache = new LRUCache<string, string>(10, 1.5);

private _actual: InnerMinimap;

constructor(context: ViewContext) {
Expand Down Expand Up @@ -1037,15 +1058,8 @@ export class Minimap extends ViewPart implements IMinimapModel {
}

public getMinimapDecorationsInViewport(startLineNumber: number, endLineNumber: number): ViewModelDecoration[] {
let visibleRange: Range;
if (this._samplingState) {
const modelStartLineNumber = this._samplingState.minimapLines[startLineNumber - 1];
const modelEndLineNumber = this._samplingState.minimapLines[endLineNumber - 1];
visibleRange = new Range(modelStartLineNumber, 1, modelEndLineNumber, this._context.viewModel.getLineMaxColumn(modelEndLineNumber));
} else {
visibleRange = new Range(startLineNumber, 1, endLineNumber, this._context.viewModel.getLineMaxColumn(endLineNumber));
}
const decorations = this._context.viewModel.getMinimapDecorationsInRange(visibleRange);
const decorations = this._getMinimapDecorationsInViewport(startLineNumber, endLineNumber)
.filter(decoration => !decoration.options.minimap?.sectionHeaderStyle);

if (this._samplingState) {
const result: ViewModelDecoration[] = [];
Expand All @@ -1063,6 +1077,41 @@ export class Minimap extends ViewPart implements IMinimapModel {
return decorations;
}

public getSectionHeaderDecorationsInViewport(startLineNumber: number, endLineNumber: number): ViewModelDecoration[] {
const minimapLineHeight = this.options.minimapLineHeight;
const sectionHeaderFontSize = this.options.sectionHeaderFontSize;
const headerHeightInMinimapLines = sectionHeaderFontSize / minimapLineHeight;
startLineNumber = Math.floor(Math.max(1, startLineNumber - headerHeightInMinimapLines));
return this._getMinimapDecorationsInViewport(startLineNumber, endLineNumber)
.filter(decoration => !!decoration.options.minimap?.sectionHeaderStyle);
}

private _getMinimapDecorationsInViewport(startLineNumber: number, endLineNumber: number) {
let visibleRange: Range;
if (this._samplingState) {
const modelStartLineNumber = this._samplingState.minimapLines[startLineNumber - 1];
const modelEndLineNumber = this._samplingState.minimapLines[endLineNumber - 1];
visibleRange = new Range(modelStartLineNumber, 1, modelEndLineNumber, this._context.viewModel.getLineMaxColumn(modelEndLineNumber));
} else {
visibleRange = new Range(startLineNumber, 1, endLineNumber, this._context.viewModel.getLineMaxColumn(endLineNumber));
}
return this._context.viewModel.getMinimapDecorationsInRange(visibleRange);
}

public getSectionHeaderText(decoration: ViewModelDecoration, fitWidth: (s: string) => string): string | null {
const headerText = decoration.options.minimap?.sectionHeaderText;
if (!headerText) {
return null;
}
const cachedText = this._sectionHeaderCache.get(headerText);
if (cachedText) {
return cachedText;
}
const fittedText = fitWidth(headerText);
this._sectionHeaderCache.set(headerText, fittedText);
return fittedText;
}

public getOptions(): TextModelResolvedOptions {
return this._context.viewModel.model.getOptions();
}
Expand Down Expand Up @@ -1469,6 +1518,7 @@ class InnerMinimap extends Disposable {
const lineOffsetMap = new ContiguousLineMap<number[] | null>(layout.startLineNumber, layout.endLineNumber, null);
this._renderSelectionsHighlights(canvasContext, selections, lineOffsetMap, layout, minimapLineHeight, tabSize, minimapCharWidth, canvasInnerWidth);
this._renderDecorationsHighlights(canvasContext, decorations, lineOffsetMap, layout, minimapLineHeight, tabSize, minimapCharWidth, canvasInnerWidth);
this._renderSectionHeaders(layout);
}
}

Expand Down Expand Up @@ -1735,6 +1785,110 @@ class InnerMinimap extends Disposable {
canvasContext.fillRect(x, y, width, height);
}

private _renderSectionHeaders(layout: MinimapLayout) {
const minimapLineHeight = this._model.options.minimapLineHeight;
const sectionHeaderFontSize = this._model.options.sectionHeaderFontSize;
const backgroundFillHeight = sectionHeaderFontSize * 1.5;
const { canvasInnerWidth } = this._model.options;

const backgroundColor = this._model.options.backgroundColor;
const backgroundFill = `rgb(${backgroundColor.r} ${backgroundColor.g} ${backgroundColor.b} / .7)`;
const foregroundColor = this._model.options.sectionHeaderFontColor;
const foregroundFill = `rgb(${foregroundColor.r} ${foregroundColor.g} ${foregroundColor.b})`;
const separatorStroke = foregroundFill;

const canvasContext = this._decorationsCanvas.domNode.getContext('2d')!;
canvasContext.font = sectionHeaderFontSize + 'px ' + this._model.options.sectionHeaderFontFamily;
canvasContext.strokeStyle = separatorStroke;
canvasContext.lineWidth = 0.2;

const decorations = this._model.getSectionHeaderDecorationsInViewport(layout.startLineNumber, layout.endLineNumber);
decorations.sort((a, b) => a.range.startLineNumber - b.range.startLineNumber);

const fitWidth = InnerMinimap._fitSectionHeader.bind(null, canvasContext,
canvasInnerWidth - MINIMAP_GUTTER_WIDTH);

for (const decoration of decorations) {
const y = layout.getYForLineNumber(decoration.range.startLineNumber, minimapLineHeight) + sectionHeaderFontSize;
const backgroundFillY = y - sectionHeaderFontSize;
const separatorY = backgroundFillY + 2;
const headerText = this._model.getSectionHeaderText(decoration, fitWidth);

InnerMinimap._renderSectionLabel(
canvasContext,
headerText,
decoration.options.minimap?.sectionHeaderStyle === MinimapSectionHeaderStyle.Underlined,
backgroundFill,
foregroundFill,
canvasInnerWidth,
backgroundFillY,
backgroundFillHeight,
y,
separatorY);
}
}

private static _fitSectionHeader(
target: CanvasRenderingContext2D,
maxWidth: number,
headerText: string,
): string {
if (!headerText) {
return headerText;
}

const ellipsis = '…';
const width = target.measureText(headerText).width;
const ellipsisWidth = target.measureText(ellipsis).width;

if (width <= maxWidth || width <= ellipsisWidth) {
return headerText;
}

const len = headerText.length;
const averageCharWidth = width / headerText.length;
const maxCharCount = Math.floor((maxWidth - ellipsisWidth) / averageCharWidth) - 1;

// Find a halfway point that isn't after whitespace
let halfCharCount = Math.ceil(maxCharCount / 2);
while (halfCharCount > 0 && /\s/.test(headerText[halfCharCount - 1])) {
--halfCharCount;
}

// Split with ellipsis
return headerText.substring(0, halfCharCount)
+ ellipsis + headerText.substring(len - (maxCharCount - halfCharCount));
}

private static _renderSectionLabel(
target: CanvasRenderingContext2D,
headerText: string | null,
hasSeparatorLine: boolean,
backgroundFill: string,
foregroundFill: string,
minimapWidth: number,
backgroundFillY: number,
backgroundFillHeight: number,
textY: number,
separatorY: number
): void {
if (headerText) {
target.fillStyle = backgroundFill;
target.fillRect(0, backgroundFillY, minimapWidth, backgroundFillHeight);

target.fillStyle = foregroundFill;
target.fillText(headerText, MINIMAP_GUTTER_WIDTH, textY);
}

if (hasSeparatorLine) {
target.beginPath();
target.moveTo(0, separatorY);
target.lineTo(minimapWidth, separatorY);
target.closePath();
target.stroke();
}
}

private renderLines(layout: MinimapLayout): RenderData | null {
const startLineNumber = layout.startLineNumber;
const endLineNumber = layout.endLineNumber;
Expand Down
33 changes: 33 additions & 0 deletions src/vs/editor/common/config/editorOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3059,6 +3059,18 @@ export interface IEditorMinimapOptions {
* Relative size of the font in the minimap. Defaults to 1.
*/
scale?: number;
/**
* Whether to show named regions as section headers. Defaults to true.
*/
showRegionSectionHeaders?: boolean;
/**
* Whether to show MARK: comments as section headers. Defaults to true.
*/
showMarkSectionHeaders?: boolean;
/**
* Font size of section headers. Defaults to 9.
*/
sectionHeaderFontSize?: number;
}

/**
Expand All @@ -3078,6 +3090,9 @@ class EditorMinimap extends BaseEditorOption<EditorOption.minimap, IEditorMinima
renderCharacters: true,
maxColumn: 120,
scale: 1,
showRegionSectionHeaders: true,
showMarkSectionHeaders: true,
sectionHeaderFontSize: 9,
};
super(
EditorOption.minimap, 'minimap', defaults,
Expand Down Expand Up @@ -3132,6 +3147,21 @@ class EditorMinimap extends BaseEditorOption<EditorOption.minimap, IEditorMinima
type: 'number',
default: defaults.maxColumn,
description: nls.localize('minimap.maxColumn', "Limit the width of the minimap to render at most a certain number of columns.")
},
'editor.minimap.showRegionSectionHeaders': {
type: 'boolean',
default: defaults.showRegionSectionHeaders,
description: nls.localize('minimap.showRegionSectionHeaders', "Controls whether named regions are shown as section headers in the minimap.")
},
'editor.minimap.showMarkSectionHeaders': {
type: 'boolean',
default: defaults.showMarkSectionHeaders,
description: nls.localize('minimap.showMarkSectionHeaders', "Controls whether MARK: comments are shown as section headers in the minimap.")
},
'editor.minimap.sectionHeaderFontSize': {
type: 'number',
default: defaults.sectionHeaderFontSize,
description: nls.localize('minimap.sectionHeaderFontSize', "Controls the font size of section headers in the minimap.")
}
}
);
Expand All @@ -3151,6 +3181,9 @@ class EditorMinimap extends BaseEditorOption<EditorOption.minimap, IEditorMinima
renderCharacters: boolean(input.renderCharacters, this.defaultValue.renderCharacters),
scale: EditorIntOption.clampedInt(input.scale, 1, 1, 3),
maxColumn: EditorIntOption.clampedInt(input.maxColumn, this.defaultValue.maxColumn, 1, 10000),
showRegionSectionHeaders: boolean(input.showRegionSectionHeaders, this.defaultValue.showRegionSectionHeaders),
showMarkSectionHeaders: boolean(input.showMarkSectionHeaders, this.defaultValue.showMarkSectionHeaders),
sectionHeaderFontSize: EditorFloatOption.clamp(input.sectionHeaderFontSize ?? this.defaultValue.sectionHeaderFontSize, 4, 32),
};
}
}
Expand Down
Loading

0 comments on commit c800bf9

Please sign in to comment.