Skip to content

Commit 9223530

Browse files
authored
Backport PR #16507: Add customisation options to prevent inline completer resizing aggressively (#16552)
1 parent 3b3fb17 commit 9223530

File tree

6 files changed

+238
-14
lines changed

6 files changed

+238
-14
lines changed

packages/completer-extension/schema/inline-completer.json

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,33 @@
6161
{ "const": "uncover", "title": "Uncover" }
6262
],
6363
"default": "uncover"
64+
},
65+
"minLines": {
66+
"title": "Reserve lines for inline completion",
67+
"description": "Number of lines to reserve for the ghost text with inline completion suggestion.",
68+
"type": "number",
69+
"default": 0,
70+
"minimum": 0
71+
},
72+
"maxLines": {
73+
"title": "Limit inline completion lines",
74+
"description": "Number of lines of inline completion to show before collapsing. Setting zero disables the limit.",
75+
"type": "number",
76+
"default": 0,
77+
"minimum": 0
78+
},
79+
"reserveSpaceForLongest": {
80+
"title": "Reserve space for the longest candidate",
81+
"description": "When multiple completions are returned, reserve blank space for up to as many lines as in the longest completion candidate to avoid resizing editor when cycling between the suggestions.",
82+
"type": "boolean",
83+
"default": false
84+
},
85+
"editorResizeDelay": {
86+
"title": "Editor resize delay",
87+
"description": "When an inline completion gets cancelled the editor may change its size rapidly. When typing in the editor, the completions may get dismissed frequently causing a noticeable jitter of the editor height. Adding a delay prevents the jitter on typing. The value should be in milliseconds.",
88+
"type": "number",
89+
"default": 1000,
90+
"minimum": 0
6491
}
6592
},
6693
"additionalProperties": false,

packages/completer/src/ghost.ts

Lines changed: 74 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const TRANSIENT_LETTER_SPACER_CLASS = 'jp-GhostText-letterSpacer';
1414
const GHOST_TEXT_CLASS = 'jp-GhostText';
1515
const STREAMED_TOKEN_CLASS = 'jp-GhostText-streamedToken';
1616
const STREAMING_INDICATOR_CLASS = 'jp-GhostText-streamingIndicator';
17+
const HIDDEN_LINES_CLASS = 'jp-GhostText-hiddenLines';
1718

1819
/**
1920
* Ghost text content and placement.
@@ -39,6 +40,14 @@ export interface IGhostText {
3940
* Whether streaming is in progress.
4041
*/
4142
streaming?: boolean;
43+
/**
44+
* Maximum number of lines to show.
45+
*/
46+
maxLines?: number;
47+
/**
48+
* Minimum number of lines to reserve (to avoid frequent resizing).
49+
*/
50+
minLines?: number;
4251
/**
4352
* Callback to execute when pointer enters the boundary of the ghost text.
4453
*/
@@ -59,6 +68,16 @@ export class GhostTextManager {
5968
*/
6069
static streamingAnimation: 'none' | 'uncover' = 'uncover';
6170

71+
/**
72+
* Delay for removal of line spacer.
73+
*/
74+
static spacerRemovalDelay: number = 700;
75+
76+
/**
77+
* Duration for line spacer removal.
78+
*/
79+
static spacerRemovalDuration: number = 300;
80+
6281
/**
6382
* Place ghost text in an editor.
6483
*/
@@ -140,33 +159,72 @@ class GhostTextWidget extends WidgetType {
140159
}
141160

142161
private _updateDOM(dom: HTMLElement) {
143-
const content = this.content;
144-
145-
if (this.isSpacer) {
146-
dom.innerText = content;
147-
return;
148-
}
149-
162+
let content = this.content;
163+
let hiddenContent = '';
150164
let addition = this.options.addedPart;
165+
151166
if (addition) {
152167
if (addition.startsWith('\n')) {
153168
// Show the new line straight away to ensure proper positioning.
154169
addition = addition.substring(1);
155170
}
156-
dom.innerText = content.substring(0, content.length - addition.length);
171+
content = content.substring(0, content.length - addition.length);
172+
}
173+
174+
if (this.options.maxLines) {
175+
// Split into content to show immediately and the hidden part
176+
const lines = content.split('\n');
177+
content = lines.slice(0, this.options.maxLines).join('\n');
178+
hiddenContent = lines.slice(this.options.maxLines).join('\n');
179+
}
180+
181+
const minLines = Math.min(
182+
this.options.minLines ?? 0,
183+
this.options.maxLines ?? Infinity
184+
);
185+
const linesToAdd = Math.max(0, minLines - content.split('\n').length + 1);
186+
const placeHolderLines = new Array(linesToAdd).fill('').join('\n');
187+
188+
if (this.isSpacer) {
189+
dom.innerText = content + placeHolderLines;
190+
return;
191+
}
192+
dom.innerText = content;
193+
194+
let streamedTokenHost = dom;
195+
196+
if (hiddenContent.length > 0) {
197+
const hiddenWrapper = document.createElement('span');
198+
hiddenWrapper.className = 'jp-GhostText-hiddenWrapper';
199+
dom.appendChild(hiddenWrapper);
200+
const expandOnHover = document.createElement('span');
201+
expandOnHover.className = 'jp-GhostText-expandHidden';
202+
expandOnHover.innerText = '⇓';
203+
const hiddenPart = document.createElement('span');
204+
hiddenWrapper.appendChild(expandOnHover);
205+
hiddenPart.className = HIDDEN_LINES_CLASS;
206+
hiddenPart.innerText = '\n' + hiddenContent;
207+
hiddenWrapper.appendChild(hiddenPart);
208+
streamedTokenHost = hiddenPart;
209+
}
210+
211+
if (addition) {
157212
const addedPart = document.createElement('span');
158213
addedPart.className = STREAMED_TOKEN_CLASS;
159214
addedPart.innerText = addition;
160-
dom.appendChild(addedPart);
161-
} else {
162-
// just set text
163-
dom.innerText = content;
215+
streamedTokenHost.appendChild(addedPart);
164216
}
217+
165218
// Add "streaming-in-progress" indicator
166219
if (this.options.streaming) {
167220
const streamingIndicator = document.createElement('span');
168221
streamingIndicator.className = STREAMING_INDICATOR_CLASS;
169-
dom.appendChild(streamingIndicator);
222+
streamedTokenHost.appendChild(streamingIndicator);
223+
}
224+
225+
if (placeHolderLines.length > 0) {
226+
const placeholderLinesNode = document.createTextNode(placeHolderLines);
227+
streamedTokenHost.appendChild(placeholderLinesNode);
170228
}
171229
}
172230
destroy(dom: HTMLElement) {
@@ -206,6 +264,9 @@ class TransientLineSpacerWidget extends TransientSpacerWidget {
206264
toDOM() {
207265
const wrap = super.toDOM();
208266
wrap.classList.add(TRANSIENT_LINE_SPACER_CLASS);
267+
wrap.style.animationDelay = GhostTextManager.spacerRemovalDelay + 'ms';
268+
wrap.style.animationDuration =
269+
GhostTextManager.spacerRemovalDuration + 'ms';
209270
return wrap;
210271
}
211272
}

packages/completer/src/inline.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,17 @@ export class InlineCompleter extends Widget {
199199
this._updateShortcutsVisibility();
200200
}
201201
GhostTextManager.streamingAnimation = settings.streamingAnimation;
202+
GhostTextManager.spacerRemovalDelay = Math.max(
203+
0,
204+
settings.editorResizeDelay - 300
205+
);
206+
GhostTextManager.spacerRemovalDuration = Math.max(
207+
0,
208+
Math.min(300, settings.editorResizeDelay - 300)
209+
);
210+
this._minLines = settings.minLines;
211+
this._maxLines = settings.maxLines;
212+
this._reserveSpaceForLongest = settings.reserveSpaceForLongest;
202213
}
203214

204215
/**
@@ -408,12 +419,26 @@ export class InlineCompleter extends Widget {
408419
}
409420

410421
const view = (editor as CodeMirrorEditor).editor;
422+
423+
let minLines: number;
424+
if (this._reserveSpaceForLongest) {
425+
const items = this.model?.completions?.items ?? [];
426+
const longest = Math.max(
427+
...items.map(i => i.insertText.split('\n').length)
428+
);
429+
minLines = Math.max(this._minLines, longest);
430+
} else {
431+
minLines = this._minLines;
432+
}
433+
411434
this._ghostManager.placeGhost(view, {
412435
from: editor.getOffsetAt(model.cursor),
413436
content: text,
414437
providerId: item.provider.identifier,
415438
addedPart: item.lastStreamed,
416439
streaming: item.streaming,
440+
minLines: minLines,
441+
maxLines: this._maxLines,
417442
onPointerOver: this._onPointerOverGhost.bind(this),
418443
onPointerLeave: this._onPointerLeaveGhost.bind(this)
419444
});
@@ -487,6 +512,8 @@ export class InlineCompleter extends Widget {
487512
private _editor: CodeEditor.IEditor | null | undefined = null;
488513
private _ghostManager: GhostTextManager;
489514
private _lastItem: CompletionHandler.IInlineItem | null = null;
515+
private _maxLines: number;
516+
private _minLines: number;
490517
private _model: InlineCompleter.IModel | null = null;
491518
private _providerWidget = new Widget();
492519
private _showShortcuts = InlineCompleter.defaultSettings.showShortcuts;
@@ -495,6 +522,7 @@ export class InlineCompleter extends Widget {
495522
private _trans: TranslationBundle;
496523
private _toolbar = new Toolbar<Widget>();
497524
private _progressBar: HTMLElement;
525+
private _reserveSpaceForLongest: boolean;
498526
}
499527

500528
/**
@@ -534,7 +562,11 @@ export namespace InlineCompleter {
534562
showWidget: 'onHover',
535563
showShortcuts: true,
536564
streamingAnimation: 'uncover',
537-
providers: {}
565+
providers: {},
566+
minLines: 2,
567+
maxLines: 4,
568+
editorResizeDelay: 1000,
569+
reserveSpaceForLongest: false
538570
};
539571

540572
/**

packages/completer/src/tokens.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,22 @@ export interface IInlineCompleterSettings {
429429
* Transition effect used when streaming tokens from model.
430430
*/
431431
streamingAnimation: 'none' | 'uncover';
432+
/**
433+
* Minimum lines to show.
434+
*/
435+
minLines: number;
436+
/**
437+
* Maximum lines to show.
438+
*/
439+
maxLines: number;
440+
/**
441+
* Delay between resizing the editor after an incline completion was cancelled.
442+
*/
443+
editorResizeDelay: number;
444+
/*
445+
* Reserve space for the longest of the completions candidates.
446+
*/
447+
reserveSpaceForLongest: boolean;
432448
/**
433449
* Provider settings.
434450
*/

packages/completer/style/base.css

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@
227227
}
228228

229229
.jp-GhostText-lineSpacer {
230+
/* duration and delay are overwritten by inline styles */
230231
animation: jp-GhostText-hide 300ms 700ms ease-out forwards;
231232
}
232233

@@ -240,6 +241,24 @@
240241
}
241242
}
242243

244+
.jp-GhostText-expandHidden {
245+
border: 1px solid var(--jp-border-color0);
246+
border-radius: var(--jp-border-radius);
247+
background: var(--jp-layout-color0);
248+
color: var(--jp-content-font-color3);
249+
padding: 0 4px;
250+
margin: 0 4px;
251+
cursor: default;
252+
}
253+
254+
.jp-GhostText-hiddenWrapper:hover > .jp-GhostText-hiddenLines {
255+
display: inline;
256+
}
257+
258+
.jp-GhostText-hiddenLines {
259+
display: none;
260+
}
261+
243262
.jp-GhostText[data-animation='uncover'] {
244263
position: relative;
245264
}

packages/completer/test/inline.spec.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import { Widget } from '@lumino/widgets';
1414
import { MessageLoop } from '@lumino/messaging';
1515
import { Doc, Text } from 'yjs';
1616

17+
const GHOST_TEXT_CLASS = 'jp-GhostText';
18+
1719
describe('completer/inline', () => {
1820
const exampleProvider: IInlineCompletionProvider = {
1921
name: 'An inline provider',
@@ -36,6 +38,8 @@ describe('completer/inline', () => {
3638
let editorWidget: CodeEditorWrapper;
3739
let model: InlineCompleter.Model;
3840
let suggestionsAbc: CompletionHandler.IInlineItem[];
41+
const findInHost = (selector: string) =>
42+
editorWidget.editor.host.querySelector(selector);
3943

4044
beforeEach(() => {
4145
editorWidget = createEditorWidget();
@@ -151,6 +155,71 @@ describe('completer/inline', () => {
151155
});
152156
expect(completer.node.dataset.showShortcuts).toBe('false');
153157
});
158+
159+
it('`maxLines` should limit the number of lines visible', async () => {
160+
Widget.attach(editorWidget, document.body);
161+
Widget.attach(completer, document.body);
162+
completer.configure({
163+
...InlineCompleter.defaultSettings,
164+
maxLines: 3
165+
});
166+
const item: CompletionHandler.IInlineItem = {
167+
...itemDefaults,
168+
insertText: 'line1\nline2\nline3\nline4\nline5'
169+
};
170+
model.setCompletions({ items: [item] });
171+
172+
const ghost = findInHost(`.${GHOST_TEXT_CLASS}`) as HTMLElement;
173+
expect(ghost.innerText).toBe('line1\nline2\nline3');
174+
});
175+
176+
const getGhostTextContent = () => {
177+
const ghost = findInHost(`.${GHOST_TEXT_CLASS}`) as HTMLElement;
178+
// jest-dom does not support textContent/innerText properly, we need to extract it manually
179+
return (
180+
ghost.innerText +
181+
[...ghost.childNodes].map(node => node.textContent).join('')
182+
);
183+
};
184+
185+
it('`minLines` should add empty lines when needed', async () => {
186+
Widget.attach(editorWidget, document.body);
187+
Widget.attach(completer, document.body);
188+
completer.configure({
189+
...InlineCompleter.defaultSettings,
190+
minLines: 3
191+
});
192+
const item: CompletionHandler.IInlineItem = {
193+
...itemDefaults,
194+
insertText: 'line1'
195+
};
196+
model.setCompletions({ items: [item] });
197+
198+
expect(getGhostTextContent()).toBe('line1\n\n');
199+
});
200+
201+
it('`reserveSpaceForLongest` should add empty lines when needed', async () => {
202+
Widget.attach(editorWidget, document.body);
203+
Widget.attach(completer, document.body);
204+
completer.configure({
205+
...InlineCompleter.defaultSettings,
206+
reserveSpaceForLongest: true
207+
});
208+
model.setCompletions({
209+
items: [
210+
{
211+
...itemDefaults,
212+
insertText: 'line1'
213+
},
214+
{
215+
...itemDefaults,
216+
insertText: 'line1\nline2\nline3'
217+
}
218+
]
219+
});
220+
221+
expect(getGhostTextContent()).toBe('line1\n\n');
222+
});
154223
});
155224

156225
describe('#cycle()', () => {

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy