Gutter indicator menu redesign (#244056)

gutter indicator menu redesign
This commit is contained in:
Benjamin Christopher Simmonds
2025-03-20 00:28:42 +01:00
committed by GitHub
parent c27b2c1fbb
commit 385dfa554c
2 changed files with 161 additions and 37 deletions
@@ -8,22 +8,25 @@ import { renderIcon } from '../../../../../../../base/browser/ui/iconLabel/iconL
import { Codicon } from '../../../../../../../base/common/codicons.js';
import { BugIndicatingError } from '../../../../../../../base/common/errors.js';
import { Disposable, DisposableStore, toDisposable } from '../../../../../../../base/common/lifecycle.js';
import { IObservable, ISettableObservable, constObservable, derived, observableFromEvent, observableValue, runOnChange } from '../../../../../../../base/common/observable.js';
import { IObservable, ISettableObservable, autorun, constObservable, derived, observableFromEvent, observableValue, runOnChange } from '../../../../../../../base/common/observable.js';
import { debouncedObservable } from '../../../../../../../base/common/observableInternal/utils.js';
import { IAccessibilityService } from '../../../../../../../platform/accessibility/common/accessibility.js';
import { IHoverService } from '../../../../../../../platform/hover/browser/hover.js';
import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js';
import { asCssVariable } from '../../../../../../../platform/theme/common/colorUtils.js';
import { IThemeService } from '../../../../../../../platform/theme/common/themeService.js';
import { IEditorMouseEvent } from '../../../../../../browser/editorBrowser.js';
import { ObservableCodeEditor } from '../../../../../../browser/observableCodeEditor.js';
import { Point } from '../../../../../../browser/point.js';
import { Rect } from '../../../../../../browser/rect.js';
import { HoverService } from '../../../../../../browser/services/hoverService/hoverService.js';
import { HoverWidget } from '../../../../../../browser/services/hoverService/hoverWidget.js';
import { EditorOption } from '../../../../../../common/config/editorOptions.js';
import { EditorOption, RenderLineNumbersType } from '../../../../../../common/config/editorOptions.js';
import { LineRange } from '../../../../../../common/core/lineRange.js';
import { OffsetRange } from '../../../../../../common/core/offsetRange.js';
import { StickyScrollController } from '../../../../../stickyScroll/browser/stickyScrollController.js';
import { IInlineEditModel, InlineEditTabAction } from '../inlineEditsViewInterface.js';
import { inlineEditIndicatorBackground, inlineEditIndicatorPrimaryBackground, inlineEditIndicatorPrimaryForeground, inlineEditIndicatorSecondaryBackground, inlineEditIndicatorSecondaryForeground, inlineEditIndicatorsuccessfulBackground, inlineEditIndicatorsuccessfulForeground } from '../theme.js';
import { getEditorBlendedColor, inlineEditIndicatorBackground, inlineEditIndicatorPrimaryBackground, inlineEditIndicatorPrimaryBorder, inlineEditIndicatorPrimaryForeground, inlineEditIndicatorSecondaryBackground, inlineEditIndicatorSecondaryBorder, inlineEditIndicatorSecondaryForeground, inlineEditIndicatorsuccessfulBackground, inlineEditIndicatorsuccessfulBorder, inlineEditIndicatorsuccessfulForeground } from '../theme.js';
import { mapOutFalsy, rectToProps } from '../utils/utils.js';
import { GutterIndicatorMenuContent } from './gutterIndicatorMenu.js';
@@ -35,8 +38,7 @@ export class InlineEditsGutterIndicator extends Disposable {
return model;
}
private readonly _gutterIndicatorBackgroundColor: IObservable<string>;
private readonly _gutterIndicatorForegroundColor: IObservable<string>;
private readonly _gutterIndicatorStyles: IObservable<{ background: string; foreground: string; border: string }>;
private readonly _isHoveredOverInlineEditDebounced: IObservable<boolean>;
constructor(
@@ -49,21 +51,27 @@ export class InlineEditsGutterIndicator extends Disposable {
@IHoverService private readonly _hoverService: HoverService,
@IInstantiationService private readonly _instantiationService: IInstantiationService,
@IAccessibilityService accessibilityService: IAccessibilityService,
@IThemeService themeService: IThemeService,
) {
super();
this._gutterIndicatorBackgroundColor = this._tabAction.map(v => {
this._gutterIndicatorStyles = this._tabAction.map((v, reader) => {
switch (v) {
case InlineEditTabAction.Inactive: return asCssVariable(inlineEditIndicatorSecondaryBackground);
case InlineEditTabAction.Jump: return asCssVariable(inlineEditIndicatorPrimaryBackground);
case InlineEditTabAction.Accept: return asCssVariable(inlineEditIndicatorsuccessfulBackground);
}
});
this._gutterIndicatorForegroundColor = this._tabAction.map(v => {
switch (v) {
case InlineEditTabAction.Inactive: return asCssVariable(inlineEditIndicatorSecondaryForeground);
case InlineEditTabAction.Jump: return asCssVariable(inlineEditIndicatorPrimaryForeground);
case InlineEditTabAction.Accept: return asCssVariable(inlineEditIndicatorsuccessfulForeground);
case InlineEditTabAction.Inactive: return {
background: getEditorBlendedColor(inlineEditIndicatorSecondaryBackground, themeService).read(reader).toString(),
foreground: getEditorBlendedColor(inlineEditIndicatorSecondaryForeground, themeService).read(reader).toString(),
border: getEditorBlendedColor(inlineEditIndicatorSecondaryBorder, themeService).read(reader).toString(),
};
case InlineEditTabAction.Jump: return {
background: getEditorBlendedColor(inlineEditIndicatorPrimaryBackground, themeService).read(reader).toString(),
foreground: getEditorBlendedColor(inlineEditIndicatorPrimaryForeground, themeService).read(reader).toString(),
border: getEditorBlendedColor(inlineEditIndicatorPrimaryBorder, themeService).read(reader).toString()
};
case InlineEditTabAction.Accept: return {
background: getEditorBlendedColor(inlineEditIndicatorsuccessfulBackground, themeService).read(reader).toString(),
foreground: getEditorBlendedColor(inlineEditIndicatorsuccessfulForeground, themeService).read(reader).toString(),
border: getEditorBlendedColor(inlineEditIndicatorsuccessfulBorder, themeService).read(reader).toString()
};
}
});
@@ -74,6 +82,18 @@ export class InlineEditsGutterIndicator extends Disposable {
minContentWidthInPx: constObservable(0),
}));
this._register(this._editorObs.editor.onMouseMove((e: IEditorMouseEvent) => {
const el = this._iconRef.element;
const rect = el.getBoundingClientRect();
const rectangularArea = Rect.fromLeftTopWidthHeight(rect.left, rect.top, rect.width, rect.height);
const point = new Point(e.event.posx, e.event.posy);
this._isHoveredOverIcon.set(rectangularArea.containsPoint(point), undefined);
}));
this._register(this._editorObs.editor.onDidScrollChange(() => {
this._isHoveredOverIcon.set(false, undefined);
}));
this._isHoveredOverInlineEditDebounced = debouncedObservable(this._isHoveringOverInlineEdit, 100);
if (!accessibilityService.isMotionReduced()) {
@@ -95,7 +115,7 @@ export class InlineEditsGutterIndicator extends Disposable {
// PULSE ANIMATION:
this._iconRef.element.animate([
{
outline: `2px solid ${this._gutterIndicatorBackgroundColor.get()}`,
outline: `2px solid ${this._gutterIndicatorStyles.map(v => v.border).get()}`,
outlineOffset: '-1px',
offset: 0
},
@@ -107,6 +127,13 @@ export class InlineEditsGutterIndicator extends Disposable {
], { duration: 500 });
}));
}
this._register(autorun(reader => {
this._indicator.readEffect(reader);
if (this._indicator.element) {
this._editorObs.editor.applyFontInfo(this._indicator.element);
}
}));
}
private readonly _originalRangeObs = mapOutFalsy(this._originalRange);
@@ -125,38 +152,95 @@ export class InlineEditsGutterIndicator extends Disposable {
? observableFromEvent(this._stickyScrollController.onDidChangeStickyScrollHeight, () => this._stickyScrollController!.stickyScrollWidgetHeight)
: constObservable(0);
private readonly _lineNumberToRender = derived(this, reader => {
if (this._verticalOffset.read(reader) !== 0) {
return '';
}
const lineNumber = this._originalRange.read(reader)?.startLineNumber;
const lineNumberOptions = this._editorObs.getOption(EditorOption.lineNumbers).read(reader);
if (lineNumber === undefined || lineNumberOptions.renderType === RenderLineNumbersType.Off) {
return '';
}
if (lineNumberOptions.renderType === RenderLineNumbersType.Interval) {
const cursorPosition = this._editorObs.cursorPosition.read(reader);
if (lineNumber % 10 === 0 || cursorPosition && cursorPosition.lineNumber === lineNumber) {
return lineNumber.toString();
}
return '';
}
if (lineNumberOptions.renderType === RenderLineNumbersType.Relative) {
const cursorPosition = this._editorObs.cursorPosition.read(reader);
if (!cursorPosition) {
return '';
}
const relativeLineNumber = Math.abs(lineNumber - cursorPosition.lineNumber);
if (relativeLineNumber === 0) {
return lineNumber.toString();
}
return relativeLineNumber.toString();
}
if (lineNumberOptions.renderType === RenderLineNumbersType.Custom) {
if (lineNumberOptions.renderFn) {
return lineNumberOptions.renderFn(lineNumber);
}
return '';
}
return lineNumber.toString();
});
private readonly _layout = derived(this, reader => {
const s = this._state.read(reader);
if (!s) { return undefined; }
const layout = this._editorObs.layoutInfo.read(reader);
const lineHeight = this._editorObs.getOption(EditorOption.lineHeight).read(reader);
const bottomPadding = 1;
const leftPadding = 1;
const rightPadding = 1;
// Entire editor area without sticky scroll
const fullViewPort = Rect.fromLeftTopRightBottom(0, 0, layout.width, layout.height - bottomPadding);
const viewPortWithStickyScroll = fullViewPort.withTop(this._stickyScrollHeight.read(reader));
// The glyph margin area across all relevant lines
const targetVertRange = s.lineOffsetRange.read(reader);
const targetRect = Rect.fromRanges(OffsetRange.fromTo(leftPadding + layout.glyphMarginLeft, layout.decorationsLeft + layout.decorationsWidth - rightPadding), targetVertRange);
const space = 1;
const targetRect = Rect.fromRanges(OffsetRange.fromTo(space + layout.glyphMarginLeft, layout.lineNumbersLeft + layout.lineNumbersWidth + 4), targetVertRange);
const lineHeight = this._editorObs.getOption(EditorOption.lineHeight).read(reader);
// The gutter view container (pill)
const pillOffset = this._verticalOffset.read(reader);
const pillRect = targetRect.withHeight(lineHeight).withWidth(22).translateY(pillOffset);
let pillRect = targetRect.withHeight(lineHeight).withWidth(22).translateY(pillOffset);
const pillRectMoved = pillRect.moveToBeContainedIn(viewPortWithStickyScroll);
const rect = targetRect;
const iconRect = (targetRect.containsRect(pillRectMoved))
// Move pill to be in viewport if it is not
pillRect = (targetRect.containsRect(pillRectMoved))
? pillRectMoved
: pillRectMoved.moveToBeContainedIn(fullViewPort.intersect(targetRect.union(fullViewPort.withHeight(lineHeight)))!); //viewPortWithStickyScroll.intersect(rect)!;
// docked = pill was already in the viewport
const docked = rect.containsRect(pillRect) && viewPortWithStickyScroll.containsRect(pillRect);
let iconDirecion = targetRect.containsRect(pillRect) ?
'right' as const
: pillRect.top > targetRect.top ?
'top' as const :
'bottom' as const;
const docked = rect.containsRect(iconRect) && viewPortWithStickyScroll.containsRect(iconRect);
let iconDirecion = (targetRect.containsRect(iconRect) ? 'right' as const
: iconRect.top > targetRect.top ? 'top' as const : 'bottom' as const);
// Grow icon the the whole glyph margin area if it is docked
let lineNumberRect = pillRect.withWidth(0);
let iconRect = pillRect;
if (docked && pillRect.top === targetRect.top + pillOffset) {
pillRect = pillRect.withWidth(layout.decorationsLeft + layout.decorationsWidth - layout.glyphMarginLeft - leftPadding - rightPadding);
lineNumberRect = pillRect.intersectHorizontal(new OffsetRange(0, layout.lineNumbersLeft + layout.lineNumbersWidth - leftPadding - 1));
iconRect = iconRect.translateX(lineNumberRect.width);
}
let icon;
if (docked && (this._isHoveredOverIconDebounced.read(reader) || this._isHoveredOverInlineEditDebounced.read(reader))) {
@@ -179,6 +263,9 @@ export class InlineEditsGutterIndicator extends Disposable {
rotation,
docked,
iconRect,
pillRect,
lineHeight,
lineNumberRect,
};
});
@@ -217,6 +304,7 @@ export class InlineEditsGutterIndicator extends Disposable {
}) as HoverWidget | undefined;
if (h) {
this._hoverVisible.set(true, undefined);
disposableStore.add(this._editorObs.editor.onDidScrollChange(() => h.dispose()));
disposableStore.add(h.onDispose(() => {
this._hoverVisible.set(false, undefined);
disposableStore.dispose();
@@ -262,23 +350,37 @@ export class InlineEditsGutterIndicator extends Disposable {
ref: this._iconRef,
onmouseenter: () => {
// TODO show hover when hovering ghost text etc.
this._isHoveredOverIcon.set(true, undefined);
this._showHover();
},
onmouseleave: () => { this._isHoveredOverIcon.set(false, undefined); },
style: {
cursor: 'pointer',
zIndex: '1000',
position: 'absolute',
backgroundColor: this._gutterIndicatorBackgroundColor,
['--vscodeIconForeground' as any]: this._gutterIndicatorForegroundColor,
backgroundColor: this._gutterIndicatorStyles.map(v => v.background),
['--vscodeIconForeground' as any]: this._gutterIndicatorStyles.map(v => v.foreground),
border: this._gutterIndicatorStyles.map(v => `1px solid ${v.border}`),
boxSizing: 'border-box',
borderRadius: '4px',
display: 'flex',
justifyContent: 'center',
transition: 'background-color 0.2s ease-in-out',
...rectToProps(reader => layout.read(reader).iconRect),
transition: 'background-color 0.2s ease-in-out, width 0.2s ease-in-out',
...rectToProps(reader => layout.read(reader).pillRect),
}
}, [
n.div({
className: 'line-number',
style: {
lineHeight: layout.map(l => `${l.lineHeight}px`),
display: layout.map(l => l.lineNumberRect.width > 0 ? 'flex' : 'none'),
alignItems: 'center',
justifyContent: 'flex-end',
width: layout.map(l => l.lineNumberRect.width),
height: '100%',
color: this._gutterIndicatorStyles.map(v => v.foreground),
}
},
this._lineNumberToRender
),
n.div({
style: {
rotate: layout.map(i => `${i.rotation}deg`),
@@ -286,6 +388,8 @@ export class InlineEditsGutterIndicator extends Disposable {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
width: layout.map(l => `${l.iconRect.width}px`),
}
}, [
layout.map(i => i.icon),
@@ -66,9 +66,19 @@ export const inlineEditIndicatorPrimaryForeground = registerColor(
buttonForeground,
localize('inlineEdit.gutterIndicator.primaryForeground', 'Foreground color for the primary inline edit gutter indicator.')
);
export const inlineEditIndicatorPrimaryBorder = registerColor(
'inlineEdit.gutterIndicator.primaryBorder',
buttonBackground,
localize('inlineEdit.gutterIndicator.primaryBorder', 'Border color for the primary inline edit gutter indicator.')
);
export const inlineEditIndicatorPrimaryBackground = registerColor(
'inlineEdit.gutterIndicator.primaryBackground',
buttonBackground,
{
light: transparent(inlineEditIndicatorPrimaryBorder, 0.5),
dark: transparent(inlineEditIndicatorPrimaryBorder, 0.4),
hcDark: transparent(inlineEditIndicatorPrimaryBorder, 0.4),
hcLight: transparent(inlineEditIndicatorPrimaryBorder, 0.5),
},
localize('inlineEdit.gutterIndicator.primaryBackground', 'Background color for the primary inline edit gutter indicator.')
);
@@ -77,9 +87,14 @@ export const inlineEditIndicatorSecondaryForeground = registerColor(
buttonSecondaryForeground,
localize('inlineEdit.gutterIndicator.secondaryForeground', 'Foreground color for the secondary inline edit gutter indicator.')
);
export const inlineEditIndicatorSecondaryBorder = registerColor(
'inlineEdit.gutterIndicator.secondaryBorder',
buttonSecondaryBackground,
localize('inlineEdit.gutterIndicator.secondaryBorder', 'Border color for the secondary inline edit gutter indicator.')
);
export const inlineEditIndicatorSecondaryBackground = registerColor(
'inlineEdit.gutterIndicator.secondaryBackground',
buttonSecondaryBackground,
inlineEditIndicatorSecondaryBorder,
localize('inlineEdit.gutterIndicator.secondaryBackground', 'Background color for the secondary inline edit gutter indicator.')
);
@@ -88,9 +103,14 @@ export const inlineEditIndicatorsuccessfulForeground = registerColor(
buttonForeground,
localize('inlineEdit.gutterIndicator.successfulForeground', 'Foreground color for the successful inline edit gutter indicator.')
);
export const inlineEditIndicatorsuccessfulBorder = registerColor(
'inlineEdit.gutterIndicator.successfulBorder',
buttonBackground,
localize('inlineEdit.gutterIndicator.successfulBorder', 'Border color for the successful inline edit gutter indicator.')
);
export const inlineEditIndicatorsuccessfulBackground = registerColor(
'inlineEdit.gutterIndicator.successfulBackground',
{ light: '#2e825c', dark: '#2e825c', hcLight: '#2e825c', hcDark: '#2e825c' },
inlineEditIndicatorsuccessfulBorder,
localize('inlineEdit.gutterIndicator.successfulBackground', 'Background color for the successful inline edit gutter indicator.')
);