Skip to content

Commit ff513be

Browse files
committed
Improve tooltip trigger when editor active, hide on mouseleave,
constrain feature events wrappers types
1 parent 8ae254b commit ff513be

File tree

7 files changed

+187
-89
lines changed

7 files changed

+187
-89
lines changed

atest/05_Features/Hover.robot

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,23 @@ Hover works in notebooks
1616
Element Text Should Be ${HOVER_SIGNAL} python_add
1717
Capture Page Screenshot 02-hover-shown.png
1818
Element Should Contain ${HOVER_BOX} python_add(a: int, b: int)
19+
# syntax highlight should work
1920
Page Should Contain Element ${HOVER_BOX} code.language-python
21+
# testing multi-element responses
2022
Element Should Contain ${HOVER_BOX} Add documentation
23+
# it should be possible to move the mouse over the tooltip in order to copy/scroll
24+
Mouse Over ${HOVER_BOX}
2125

2226
Hover works in foreign code (javascript)
2327
Enter Cell Editor 2
2428
Hover Over js_add
2529
Capture Page Screenshot 02-hover-shown.png
2630
Element Should Contain ${HOVER_BOX} function js_add(a: any, b: any): any
2731
Page Should Contain Element ${HOVER_BOX} code.language-typescript
32+
# should be hidden once moving the mouse away
33+
Mouse Over ${STATUSBAR}
34+
Page Should Not Contain Element ${HOVER_BOX}
35+
Page Should Not Contain Element ${HOVER_SIGNAL}
2836
# also for multiple cells of the same document
2937
Enter Cell Editor 3
3038
Hover Over Math
@@ -34,15 +42,15 @@ Hover works in foreign code (javascript)
3442
Hover Over
3543
[Arguments] ${symbol}
3644
${sel} = Set Variable If "${symbol}".startswith(("xpath", "css")) ${symbol} xpath:(//span[@role="presentation"][contains(., "${symbol}")])[last()]
37-
Wait Until Keyword Succeeds 10 x 0.1 s Trigger Tooltip ${sel}
45+
Wait Until Keyword Succeeds 2x 0.1 s Trigger Tooltip ${sel}
3846

3947
Trigger Tooltip
4048
[Arguments] ${sel}
41-
Wait Until Keyword Succeeds 10 x 0.1 s Mouse Over ${sel}
42-
Click Element ${sel}
49+
Mouse Over ${sel}
4350
Wait Until Page Contains Element ${HOVER_SIGNAL}
51+
Click Element ${sel}
4452
Press Keys ${sel} CTRL
45-
Wait Until Keyword Succeeds 10x 0.1s Page Should Contain Element ${HOVER_BOX}
53+
Wait Until Keyword Succeeds 2x 0.1s Page Should Contain Element ${HOVER_BOX}
4654

4755
Setup Hover Test
4856
Setup Notebook Python Hover.ipynb

packages/jupyterlab-lsp/src/components/free_tooltip.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ import { IEditorPosition } from '../positioning';
99
import { PositionConverter } from '../converter';
1010
import { Widget } from '@lumino/widgets';
1111
import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
12-
import { ILSPAdapterManager } from '../tokens';
12+
import { WidgetAdapter } from '../adapters/adapter';
13+
import { IDocumentWidget } from '@jupyterlab/docregistry';
1314

1415
const MIN_HEIGHT = 20;
1516
const MAX_HEIGHT = 250;
@@ -95,22 +96,19 @@ export namespace EditorTooltip {
9596
markup: lsProtocol.MarkupContent;
9697
ce_editor: CodeEditor.IEditor;
9798
position: IEditorPosition;
99+
adapter: WidgetAdapter<IDocumentWidget>;
98100
className?: string;
99101
}
100102
}
101103

102104
export class EditorTooltipManager {
103105
private currentTooltip: FreeTooltip = null;
104106

105-
constructor(
106-
private rendermime_registry: IRenderMimeRegistry,
107-
private adapterManager: ILSPAdapterManager
108-
) {}
107+
constructor(private rendermime_registry: IRenderMimeRegistry) {}
109108

110109
create(options: EditorTooltip.IOptions): FreeTooltip {
111110
this.remove();
112-
let { markup, position } = options;
113-
let adapter = this.adapterManager.currentAdapter;
111+
let { markup, position, adapter } = options;
114112
let widget = adapter.widget;
115113
const bundle =
116114
markup.kind === 'plaintext'

packages/jupyterlab-lsp/src/editor_integration/codemirror.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,35 @@ export interface IEditOutcome {
5555
errors: string[];
5656
}
5757

58+
/**
59+
* Interface for storage of HTMLElement event specifications (event name + handler).
60+
*/
61+
interface IHTMLEventMap<
62+
T extends keyof HTMLElementEventMap = keyof HTMLElementEventMap
63+
> extends Map<T, (event: HTMLElementEventMap[T]) => void> {
64+
set<E extends T>(
65+
k: E,
66+
handler: (event: HTMLElementEventMap[E]) => void
67+
): this;
68+
get<E extends T>(k: E): (event: HTMLElementEventMap[E]) => void;
69+
}
70+
71+
type CodeMirrorEventName =
72+
| CodeMirror.DOMEvent
73+
| 'change'
74+
| 'changes'
75+
| 'beforeChange'
76+
| 'cursorActivity'
77+
| 'beforeSelectionChange'
78+
| 'viewportChange'
79+
| 'gutterClick'
80+
| 'focus'
81+
| 'blur'
82+
| 'scroll'
83+
| 'update'
84+
| 'renderLine'
85+
| 'overwriteToggle';
86+
5887
/**
5988
* One feature of each type exists per VirtualDocument
6089
* (the initialization is performed by the adapter).
@@ -64,9 +93,16 @@ export abstract class CodeMirrorIntegration
6493
is_registered: boolean;
6594
feature: IFeature;
6695

67-
protected readonly editor_handlers: Map<string, CodeMirrorHandler>;
68-
protected readonly connection_handlers: Map<string, any>;
69-
protected readonly wrapper_handlers: Map<keyof HTMLElementEventMap, any>;
96+
protected readonly editor_handlers: Map<
97+
CodeMirrorEventName,
98+
CodeMirrorHandler
99+
>;
100+
// TODO use better type constraints for connection event names and for responses
101+
protected readonly connection_handlers: Map<
102+
string,
103+
(response: object) => void
104+
>;
105+
protected readonly wrapper_handlers: IHTMLEventMap;
70106
protected wrapper: HTMLElement;
71107

72108
protected virtual_editor: CodeMirrorVirtualEditor;

packages/jupyterlab-lsp/src/features/hover.ts

Lines changed: 97 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,13 @@ import {
88
IEditorRange
99
} from '../editor_integration/codemirror';
1010
import { FeatureSettings, IFeatureLabIntegration } from '../feature';
11-
import { EditorTooltipManager } from '../components/free_tooltip';
11+
import { EditorTooltipManager, FreeTooltip } from '../components/free_tooltip';
1212
import {
1313
JupyterFrontEnd,
1414
JupyterFrontEndPlugin
1515
} from '@jupyterlab/application';
1616
import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
17-
import { ILSPAdapterManager, ILSPFeatureManager, PLUGIN_ID } from '../tokens';
17+
import { ILSPFeatureManager, PLUGIN_ID } from '../tokens';
1818
import { ISettingRegistry } from '@jupyterlab/settingregistry';
1919
import { CodeHover as LSPHoverSettings, ModifierKey } from '../_hover';
2020
import { LabIcon } from '@jupyterlab/ui-components';
@@ -86,8 +86,9 @@ export class HoverCM extends CodeMirrorIntegration {
8686
protected cache: ResponseCache;
8787

8888
private debounced_get_hover: Throttler<Promise<lsProtocol.Hover>>;
89+
private tooltip: FreeTooltip;
8990

90-
get modifierKey(): ModifierKey {
91+
protected get modifierKey(): ModifierKey {
9192
return this.settings.composite.modifierKey;
9293
}
9394

@@ -114,31 +115,34 @@ export class HoverCM extends CodeMirrorIntegration {
114115

115116
register(): void {
116117
this.cache = new ResponseCache(this.settings.composite.cacheSize);
117-
this.wrapper_handlers.set('mousemove', this.handleMouseOver);
118-
this.wrapper_handlers.set('mouseleave', this.remove_range_highlight);
119-
// show hover after pressing the modifier key
120-
this.wrapper_handlers.set('keydown', (event: KeyboardEvent) => {
121-
if (
122-
getModifierState(event, this.modifierKey) &&
123-
typeof this.last_hover_character !== 'undefined'
124-
) {
125-
const document = this.virtual_editor.document_at_root_position(
126-
this.last_hover_character
127-
);
128-
const virtual_position = this.virtual_editor.root_position_to_virtual_position(
129-
this.last_hover_character
130-
);
131-
let response_data = this.restore_from_cache(
132-
this.last_hover_character,
133-
document,
134-
virtual_position
135-
);
136-
if (response_data == null) {
137-
return;
138-
}
139-
this.handleResponse(response_data, this.last_hover_character, true);
140-
}
118+
119+
this.wrapper_handlers.set('mousemove', event => {
120+
// as CodeMirror.Editor does not support mouseleave nor mousemove,
121+
// we simulate the mouseleave for the editor in wrapper's mousemove;
122+
// this is used to hide the tooltip on leaving cells in notebook
123+
this.updateUnderlineAndTooltip(event)
124+
.then(keep_tooltip => {
125+
if (!keep_tooltip) {
126+
this.maybeHideTooltip(event.target);
127+
}
128+
})
129+
.catch(console.warn);
141130
});
131+
this.wrapper_handlers.set('mouseleave', this.onMouseLeave);
132+
133+
// show hover after pressing the modifier key
134+
// TODO: when the editor (notebook or file editor) is not focused, the keydown event is not getting to us
135+
// (probably getting captured by lab); this gives subpar experience when using hover in two editors open
136+
// side-by-side, BUT this does not happen for mousemove which properly reads keyModifier from the event
137+
// (so this is no too bad as most of the time the user will get the desired outcome - they just need to
138+
// budge the mice when holding ctrl if looking at a document which is not active).
139+
// whether the editor is focused
140+
this.wrapper_handlers.set('keydown', this.onKeyDown);
141+
// or just the wrapper (e.g. the notebook but no cell active)
142+
this.editor_handlers.set('keydown', (instance, event: KeyboardEvent) =>
143+
this.onKeyDown(event)
144+
);
145+
142146
this.debounced_get_hover = this.create_throttler();
143147

144148
this.settings.changed.connect(() => {
@@ -148,7 +152,45 @@ export class HoverCM extends CodeMirrorIntegration {
148152
super.register();
149153
}
150154

151-
create_throttler() {
155+
protected onKeyDown = (event: KeyboardEvent) => {
156+
if (
157+
getModifierState(event, this.modifierKey) &&
158+
typeof this.last_hover_character !== 'undefined'
159+
) {
160+
const document = this.virtual_editor.document_at_root_position(
161+
this.last_hover_character
162+
);
163+
const virtual_position = this.virtual_editor.root_position_to_virtual_position(
164+
this.last_hover_character
165+
);
166+
let response_data = this.restore_from_cache(
167+
this.last_hover_character,
168+
document,
169+
virtual_position
170+
);
171+
if (response_data == null) {
172+
return;
173+
}
174+
event.stopPropagation();
175+
this.handleResponse(response_data, this.last_hover_character, true);
176+
}
177+
};
178+
179+
protected onMouseLeave = (event: MouseEvent) => {
180+
this.remove_range_highlight();
181+
this.maybeHideTooltip(event.relatedTarget);
182+
};
183+
184+
protected maybeHideTooltip(mouse_target: EventTarget) {
185+
if (
186+
typeof this.tooltip !== 'undefined' &&
187+
mouse_target !== this.tooltip.node
188+
) {
189+
this.tooltip.dispose();
190+
}
191+
}
192+
193+
protected create_throttler() {
152194
return new Throttler<Promise<lsProtocol.Hover>>(this.on_hover, {
153195
limit: this.settings.composite.throttlerDelay,
154196
edge: 'trailing'
@@ -203,11 +245,17 @@ export class HoverCM extends CodeMirrorIntegration {
203245
}
204246
}
205247

248+
/**
249+
* Underlines the word if a tooltip is available.
250+
* Displays tooltip if asked to do so.
251+
*
252+
* Returns true is the tooltip was shown.
253+
*/
206254
public handleResponse = (
207255
response_data: IResponseData,
208256
root_position: IRootPosition,
209257
show_tooltip: boolean
210-
) => {
258+
): boolean => {
211259
let response = response_data.response;
212260

213261
// testing for object equality because the response will likely be reused from cache
@@ -228,13 +276,16 @@ export class HoverCM extends CodeMirrorIntegration {
228276
root_position
229277
);
230278

231-
this.lab_integration.tooltip.create({
279+
this.tooltip = this.lab_integration.tooltip.create({
232280
markup,
233281
position: editor_position,
234282
ce_editor: response_data.ce_editor,
283+
adapter: this.adapter,
235284
className: 'lsp-hover'
236285
});
286+
return true;
237287
}
288+
return false;
238289
};
239290

240291
protected is_token_empty(token: CodeMirror.Token) {
@@ -255,7 +306,12 @@ export class HoverCM extends CodeMirrorIntegration {
255306
);
256307
}
257308

258-
protected async _handleMouseOver(event: MouseEvent) {
309+
/**
310+
* Returns true if the tooltip should stay.
311+
*/
312+
protected async _updateUnderlineAndTooltip(
313+
event: MouseEvent
314+
): Promise<boolean> {
259315
const show_tooltip = getModifierState(event, this.modifierKey);
260316

261317
// currently the events are coming from notebook panel; ideally these would be connected to individual cells,
@@ -332,13 +388,15 @@ export class HoverCM extends CodeMirrorIntegration {
332388
}
333389
}
334390

335-
this.handleResponse(response_data, root_position, show_tooltip);
391+
return this.handleResponse(response_data, root_position, show_tooltip);
392+
} else {
393+
return true;
336394
}
337395
}
338396

339-
protected handleMouseOver = (event: MouseEvent) => {
397+
protected updateUnderlineAndTooltip = (event: MouseEvent) => {
340398
try {
341-
return this._handleMouseOver(event);
399+
return this._updateUnderlineAndTooltip(event);
342400
} catch (e) {
343401
if (
344402
!(
@@ -435,37 +493,29 @@ class HoverLabIntegration implements IFeatureLabIntegration {
435493
constructor(
436494
app: JupyterFrontEnd,
437495
settings: FeatureSettings<any>,
438-
renderMimeRegistry: IRenderMimeRegistry,
439-
adapterManager: ILSPAdapterManager
496+
renderMimeRegistry: IRenderMimeRegistry
440497
) {
441-
this.tooltip = new EditorTooltipManager(renderMimeRegistry, adapterManager);
498+
this.tooltip = new EditorTooltipManager(renderMimeRegistry);
442499
}
443500
}
444501

445502
const FEATURE_ID = PLUGIN_ID + ':hover';
446503

447504
export const HOVER_PLUGIN: JupyterFrontEndPlugin<void> = {
448505
id: FEATURE_ID,
449-
requires: [
450-
ILSPFeatureManager,
451-
ISettingRegistry,
452-
IRenderMimeRegistry,
453-
ILSPAdapterManager
454-
],
506+
requires: [ILSPFeatureManager, ISettingRegistry, IRenderMimeRegistry],
455507
autoStart: true,
456508
activate: (
457509
app: JupyterFrontEnd,
458510
featureManager: ILSPFeatureManager,
459511
settingRegistry: ISettingRegistry,
460-
renderMimeRegistry: IRenderMimeRegistry,
461-
adapterManager: ILSPAdapterManager
512+
renderMimeRegistry: IRenderMimeRegistry
462513
) => {
463514
const settings = new FeatureSettings(settingRegistry, FEATURE_ID);
464515
const labIntegration = new HoverLabIntegration(
465516
app,
466517
settings,
467-
renderMimeRegistry,
468-
adapterManager
518+
renderMimeRegistry
469519
);
470520

471521
featureManager.register({

0 commit comments

Comments
 (0)